@remixhq/core 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/collab.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  getCollabBindingPath,
3
3
  readCollabBinding,
4
+ readCollabBindingState,
4
5
  reserveAvailableDirPath,
5
6
  reserveDirectory,
6
7
  writeCollabBinding
7
- } from "./chunk-GEHSFPCD.js";
8
+ } from "./chunk-4L3ZBZUQ.js";
9
+ import "./chunk-HZNEDSRS.js";
8
10
  import {
9
11
  assertRepoSnapshotUnchanged,
10
12
  buildRepoFingerprint,
@@ -38,7 +40,7 @@ import {
38
40
  summarizeUnifiedDiff,
39
41
  validateUnifiedDiff,
40
42
  writeTempUnifiedDiffBackup
41
- } from "./chunk-J3J4PBQ7.js";
43
+ } from "./chunk-RREREIGW.js";
42
44
  import {
43
45
  REMIX_ERROR_CODES
44
46
  } from "./chunk-GC2MOT3U.js";
@@ -55,29 +57,29 @@ function describeBranch(value) {
55
57
  const normalized = String(value ?? "").trim();
56
58
  return normalized || "(detached)";
57
59
  }
58
- function isPreferredBranchMatch(currentBranch, preferredBranch) {
60
+ function isBoundBranchMatch(currentBranch, branchName) {
59
61
  const current = String(currentBranch ?? "").trim();
60
- const preferred = String(preferredBranch ?? "").trim();
61
- if (!preferred || !current) return true;
62
- return current === preferred;
62
+ const expected = String(branchName ?? "").trim();
63
+ if (!expected || !current) return true;
64
+ return current === expected;
63
65
  }
64
- function buildPreferredBranchMismatchHint(params) {
66
+ function buildBranchMismatchHint(params) {
65
67
  const overrideFlag = params.overrideFlag?.trim() || "--allow-branch-mismatch";
66
68
  return [
67
69
  `Current branch: ${describeBranch(params.currentBranch)}`,
68
- `Preferred branch: ${describeBranch(params.preferredBranch)}`,
69
- `Switch to ${describeBranch(params.preferredBranch)} or rerun with ${overrideFlag} if this is intentional.`
70
+ `Bound branch: ${describeBranch(params.branchName)}`,
71
+ `Switch to ${describeBranch(params.branchName)} or rerun with ${overrideFlag} if this is intentional.`
70
72
  ].join("\n");
71
73
  }
72
- function assertPreferredBranchMatch(params) {
74
+ function assertBoundBranchMatch(params) {
73
75
  if (params.allowBranchMismatch) return;
74
- if (isPreferredBranchMatch(params.currentBranch, params.preferredBranch)) return;
75
- throw new RemixError(`Current branch does not match this checkout's Remix preferred branch while running ${params.operation}.`, {
76
+ if (isBoundBranchMatch(params.currentBranch, params.branchName)) return;
77
+ throw new RemixError(`Current branch does not match this checkout's bound Remix branch while running ${params.operation}.`, {
76
78
  code: REMIX_ERROR_CODES.PREFERRED_BRANCH_MISMATCH,
77
79
  exitCode: 2,
78
- hint: buildPreferredBranchMismatchHint({
80
+ hint: buildBranchMismatchHint({
79
81
  currentBranch: params.currentBranch,
80
- preferredBranch: params.preferredBranch,
82
+ branchName: params.branchName,
81
83
  overrideFlag: params.overrideFlag
82
84
  })
83
85
  });
@@ -141,6 +143,52 @@ function sanitizeCheckoutDirName(value) {
141
143
  function buildDashboardAppUrl(appId) {
142
144
  return `https://dashboard.remix.one/apps/${encodeURIComponent(appId)}`;
143
145
  }
146
+ async function resolveProjectLaneIfAuthoritative(api, params) {
147
+ const branchName = String(params.branchName ?? "").trim();
148
+ if (!branchName) return null;
149
+ if (!params.projectId && !params.repoFingerprint && !params.remoteUrl) return null;
150
+ const readLane = async () => {
151
+ const laneResp = await api.resolveProjectLaneBinding({
152
+ projectId: params.projectId ?? void 0,
153
+ repoFingerprint: params.repoFingerprint ?? void 0,
154
+ remoteUrl: params.remoteUrl ?? void 0,
155
+ defaultBranch: params.defaultBranch ?? void 0,
156
+ branchName
157
+ });
158
+ return unwrapResponseObject(laneResp, "project lane binding");
159
+ };
160
+ let lane = await readLane();
161
+ const shouldRepairResolvedLane = lane.status === "resolved" && Boolean(
162
+ params.expectedUpstreamAppId && (!lane.upstreamAppId || lane.upstreamAppId !== params.expectedUpstreamAppId)
163
+ );
164
+ if (shouldRepairResolvedLane && params.createIfMissing && params.seedAppId) {
165
+ const ensuredResp = await api.ensureProjectLaneBinding({
166
+ projectId: params.projectId ?? void 0,
167
+ repoFingerprint: params.repoFingerprint ?? void 0,
168
+ remoteUrl: params.remoteUrl ?? void 0,
169
+ defaultBranch: params.defaultBranch ?? void 0,
170
+ branchName,
171
+ seedAppId: params.seedAppId
172
+ });
173
+ lane = unwrapResponseObject(ensuredResp, "project lane binding");
174
+ }
175
+ if (lane.status !== "resolved" && params.createIfMissing && params.seedAppId) {
176
+ const ensuredResp = await api.ensureProjectLaneBinding({
177
+ projectId: params.projectId ?? void 0,
178
+ repoFingerprint: params.repoFingerprint ?? void 0,
179
+ remoteUrl: params.remoteUrl ?? void 0,
180
+ defaultBranch: params.defaultBranch ?? void 0,
181
+ branchName,
182
+ seedAppId: params.seedAppId
183
+ });
184
+ lane = unwrapResponseObject(ensuredResp, "project lane binding");
185
+ }
186
+ if (lane.status !== "resolved") return null;
187
+ if (params.projectId && lane.projectId && lane.projectId !== params.projectId) return null;
188
+ if (params.currentAppId && lane.currentAppId && lane.currentAppId !== params.currentAppId) return null;
189
+ if (params.expectedUpstreamAppId && lane.upstreamAppId && lane.upstreamAppId !== params.expectedUpstreamAppId) return null;
190
+ return lane;
191
+ }
144
192
  async function pollAppReady(api, appId) {
145
193
  const started = Date.now();
146
194
  let delay = 2e3;
@@ -285,6 +333,275 @@ async function pollMergeRequestCompletion(api, mrId, params) {
285
333
  throw new RemixError("Timed out waiting for merge approval to complete.", { exitCode: 1 });
286
334
  }
287
335
 
336
+ // src/application/collab/laneBindingResolver.ts
337
+ function normalizeBranchName(value) {
338
+ const normalized = String(value ?? "").trim();
339
+ return normalized || null;
340
+ }
341
+ function buildBindingFromLane(state, lane) {
342
+ if (!lane.currentAppId || !lane.upstreamAppId) return null;
343
+ return {
344
+ schemaVersion: 3,
345
+ projectId: lane.projectId ?? state.projectId,
346
+ currentAppId: lane.currentAppId,
347
+ upstreamAppId: lane.upstreamAppId,
348
+ threadId: lane.threadId ?? null,
349
+ repoFingerprint: lane.repoFingerprint ?? state.repoFingerprint ?? null,
350
+ remoteUrl: lane.remoteUrl ?? state.remoteUrl ?? null,
351
+ defaultBranch: lane.defaultBranch ?? state.defaultBranch ?? null,
352
+ laneId: lane.laneId ?? null,
353
+ branchName: lane.branchName ?? state.currentBranch ?? null,
354
+ bindingMode: "lane"
355
+ };
356
+ }
357
+ function shouldPersistRemoteLaneMetadata(localBinding, lane) {
358
+ return Boolean(
359
+ !localBinding.laneId && lane.laneId || !localBinding.threadId && lane.threadId || !localBinding.repoFingerprint && lane.repoFingerprint || !localBinding.remoteUrl && lane.remoteUrl || !localBinding.defaultBranch && lane.defaultBranch || !localBinding.branchName && lane.branchName
360
+ );
361
+ }
362
+ function shouldRequireRemoteLaneForCurrentBranch(params) {
363
+ if (!params.currentBranch) return false;
364
+ const defaultBranch = normalizeBranchName(params.defaultBranch);
365
+ if (params.currentBranch === defaultBranch) return false;
366
+ return !params.binding.laneId || params.binding.currentAppId === params.binding.upstreamAppId;
367
+ }
368
+ async function persistResolvedLane(repoRoot, binding) {
369
+ await writeCollabBinding(repoRoot, {
370
+ projectId: binding.projectId,
371
+ currentAppId: binding.currentAppId,
372
+ upstreamAppId: binding.upstreamAppId,
373
+ threadId: binding.threadId,
374
+ repoFingerprint: binding.repoFingerprint,
375
+ remoteUrl: binding.remoteUrl,
376
+ defaultBranch: binding.defaultBranch,
377
+ laneId: binding.laneId,
378
+ branchName: binding.branchName,
379
+ bindingMode: binding.bindingMode
380
+ });
381
+ return readCollabBinding(repoRoot);
382
+ }
383
+ async function resolveActiveLaneBinding(params) {
384
+ const state = await readCollabBindingState(params.repoRoot);
385
+ if (!state) {
386
+ return { status: "not_bound", currentBranch: null };
387
+ }
388
+ const currentBranch = normalizeBranchName(state.currentBranch);
389
+ const localBinding = state.binding;
390
+ if (localBinding) {
391
+ const requireRemoteLane = shouldRequireRemoteLaneForCurrentBranch({
392
+ binding: localBinding,
393
+ currentBranch,
394
+ defaultBranch: state.defaultBranch
395
+ });
396
+ if (!params.api || !currentBranch) {
397
+ return {
398
+ status: "resolved",
399
+ source: "local",
400
+ binding: localBinding,
401
+ currentBranch
402
+ };
403
+ }
404
+ const laneResp2 = await params.api.resolveProjectLaneBinding({
405
+ projectId: localBinding.projectId ?? state.projectId ?? void 0,
406
+ repoFingerprint: state.repoFingerprint ?? void 0,
407
+ remoteUrl: state.remoteUrl ?? void 0,
408
+ defaultBranch: state.defaultBranch ?? void 0,
409
+ branchName: currentBranch
410
+ });
411
+ const lane2 = unwrapResponseObject(laneResp2, "project lane binding");
412
+ if (lane2.status === "resolved") {
413
+ const resolvedBranch = normalizeBranchName(lane2.branchName);
414
+ const resolvedProjectId = lane2.projectId ?? state.projectId;
415
+ const branchConflict = Boolean(resolvedBranch && localBinding.branchName && resolvedBranch !== localBinding.branchName);
416
+ const appConflict = Boolean(lane2.currentAppId && lane2.currentAppId !== localBinding.currentAppId);
417
+ const upstreamConflict = Boolean(lane2.upstreamAppId && lane2.upstreamAppId !== localBinding.upstreamAppId);
418
+ const projectConflict = Boolean(resolvedProjectId && localBinding.projectId && resolvedProjectId !== localBinding.projectId);
419
+ if (branchConflict || appConflict || upstreamConflict || projectConflict) {
420
+ if (requireRemoteLane) {
421
+ const binding = buildBindingFromLane(state, lane2);
422
+ if (binding) {
423
+ return {
424
+ status: "resolved",
425
+ source: "remote",
426
+ binding,
427
+ currentBranch
428
+ };
429
+ }
430
+ }
431
+ return {
432
+ status: "binding_conflict",
433
+ binding: localBinding,
434
+ resolvedLane: lane2,
435
+ currentBranch
436
+ };
437
+ }
438
+ if (shouldPersistRemoteLaneMetadata(localBinding, lane2)) {
439
+ const binding = buildBindingFromLane(state, lane2);
440
+ if (binding) {
441
+ return {
442
+ status: "resolved",
443
+ source: "remote",
444
+ binding,
445
+ currentBranch
446
+ };
447
+ }
448
+ }
449
+ }
450
+ if (requireRemoteLane) {
451
+ return {
452
+ status: "missing_branch_binding",
453
+ currentBranch,
454
+ projectId: state.projectId,
455
+ repoFingerprint: state.repoFingerprint,
456
+ remoteUrl: state.remoteUrl,
457
+ defaultBranch: state.defaultBranch,
458
+ upstreamAppId: localBinding.upstreamAppId ?? null,
459
+ threadId: localBinding.threadId ?? null
460
+ };
461
+ }
462
+ return {
463
+ status: "resolved",
464
+ source: "local",
465
+ binding: localBinding,
466
+ currentBranch
467
+ };
468
+ }
469
+ if (!params.api || !currentBranch) {
470
+ return {
471
+ status: "missing_branch_binding",
472
+ currentBranch,
473
+ projectId: state.projectId,
474
+ repoFingerprint: state.repoFingerprint,
475
+ remoteUrl: state.remoteUrl,
476
+ defaultBranch: state.defaultBranch,
477
+ upstreamAppId: null,
478
+ threadId: null
479
+ };
480
+ }
481
+ const laneResp = await params.api.resolveProjectLaneBinding({
482
+ projectId: state.projectId ?? void 0,
483
+ repoFingerprint: state.repoFingerprint ?? void 0,
484
+ remoteUrl: state.remoteUrl ?? void 0,
485
+ defaultBranch: state.defaultBranch ?? void 0,
486
+ branchName: currentBranch
487
+ });
488
+ const lane = unwrapResponseObject(laneResp, "project lane binding");
489
+ if (lane.status === "resolved") {
490
+ const binding = buildBindingFromLane(state, lane);
491
+ if (binding) {
492
+ return {
493
+ status: "resolved",
494
+ source: "remote",
495
+ binding,
496
+ currentBranch
497
+ };
498
+ }
499
+ }
500
+ if (lane.status === "binding_not_found") {
501
+ return { status: "not_bound", currentBranch };
502
+ }
503
+ return {
504
+ status: "missing_branch_binding",
505
+ currentBranch,
506
+ projectId: lane.projectId ?? state.projectId,
507
+ repoFingerprint: lane.repoFingerprint ?? state.repoFingerprint,
508
+ remoteUrl: lane.remoteUrl ?? state.remoteUrl,
509
+ defaultBranch: lane.defaultBranch ?? state.defaultBranch,
510
+ upstreamAppId: lane.upstreamAppId ?? null,
511
+ threadId: lane.threadId ?? null
512
+ };
513
+ }
514
+ async function ensureActiveLaneBinding(params) {
515
+ const resolved = await resolveActiveLaneBinding({
516
+ repoRoot: params.repoRoot,
517
+ api: params.api
518
+ });
519
+ if (resolved.status === "resolved") {
520
+ if (resolved.source === "local") {
521
+ return resolved.binding;
522
+ }
523
+ return persistResolvedLane(params.repoRoot, resolved.binding);
524
+ }
525
+ if (resolved.status === "binding_conflict") {
526
+ throw new RemixError("Current branch binding conflicts with the server-resolved Remix lane.", {
527
+ exitCode: 2,
528
+ hint: `Local app ${resolved.binding.currentAppId}; server app ${resolved.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before running ${params.operation ?? "this command"}.`
529
+ });
530
+ }
531
+ if (resolved.status === "not_bound") {
532
+ return null;
533
+ }
534
+ if (!resolved.currentBranch) {
535
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
536
+ exitCode: 2,
537
+ hint: `Switch to a named branch before running ${params.operation ?? "this command"}.`
538
+ });
539
+ }
540
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
541
+ exitCode: 2,
542
+ hint: `Run \`remix collab init\` on branch ${resolved.currentBranch} before running ${params.operation ?? "this command"}.`
543
+ });
544
+ }
545
+ async function provisionActiveLaneBinding(params) {
546
+ const resolved = await resolveActiveLaneBinding(params);
547
+ if (resolved.status === "resolved") {
548
+ if (resolved.source === "local") {
549
+ return { binding: resolved.binding, warnings: [] };
550
+ }
551
+ const persisted2 = await persistResolvedLane(params.repoRoot, resolved.binding);
552
+ return { binding: persisted2, warnings: [] };
553
+ }
554
+ if (resolved.status === "binding_conflict") {
555
+ throw new RemixError("Current branch binding conflicts with the server-resolved Remix lane.", {
556
+ exitCode: 2,
557
+ hint: `Local app ${resolved.binding.currentAppId}; server app ${resolved.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before running ${params.operation}.`
558
+ });
559
+ }
560
+ if (resolved.status === "not_bound") {
561
+ return { binding: null, warnings: [] };
562
+ }
563
+ if (!resolved.currentBranch) {
564
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
565
+ exitCode: 2,
566
+ hint: `Switch to a named branch before running ${params.operation}.`
567
+ });
568
+ }
569
+ let lane;
570
+ try {
571
+ const laneResp = await params.api.ensureProjectLaneBinding({
572
+ projectId: resolved.projectId ?? void 0,
573
+ repoFingerprint: resolved.repoFingerprint ?? void 0,
574
+ remoteUrl: resolved.remoteUrl ?? void 0,
575
+ defaultBranch: resolved.defaultBranch ?? void 0,
576
+ branchName: resolved.currentBranch
577
+ });
578
+ lane = unwrapResponseObject(laneResp, "project lane binding");
579
+ } catch (error) {
580
+ throw new RemixError(`Failed to provision a Remix lane for branch ${resolved.currentBranch}.`, {
581
+ exitCode: error instanceof RemixError ? error.exitCode : 1,
582
+ hint: formatCliErrorDetail(error) || `Remix could not create or recover the branch lane required before ${params.operation}.`
583
+ });
584
+ }
585
+ const state = await readCollabBindingState(params.repoRoot);
586
+ if (!state) {
587
+ return { binding: null, warnings: [] };
588
+ }
589
+ const binding = buildBindingFromLane(state, lane);
590
+ if (!binding) {
591
+ throw new RemixError(`Failed to provision a Remix lane for branch ${resolved.currentBranch}.`, {
592
+ exitCode: 1,
593
+ hint: "The server returned incomplete lane binding metadata."
594
+ });
595
+ }
596
+ const persisted = await persistResolvedLane(params.repoRoot, binding);
597
+ return {
598
+ binding: persisted,
599
+ warnings: [
600
+ lane.created ? `Provisioned Remix lane for branch ${resolved.currentBranch}.` : `Recovered existing Remix lane binding for branch ${resolved.currentBranch}.`
601
+ ]
602
+ };
603
+ }
604
+
288
605
  // src/application/collab/recordingPreflight.ts
289
606
  async function collabRecordingPreflight(params) {
290
607
  let repoRoot;
@@ -297,7 +614,7 @@ async function collabRecordingPreflight(params) {
297
614
  repoRoot: null,
298
615
  appId: null,
299
616
  currentBranch: null,
300
- preferredBranch: null,
617
+ branchName: null,
301
618
  headCommitHash: null,
302
619
  worktreeClean: false,
303
620
  syncStatus: null,
@@ -309,14 +626,14 @@ async function collabRecordingPreflight(params) {
309
626
  hint: message
310
627
  };
311
628
  }
312
- const binding = await readCollabBinding(repoRoot);
313
- if (!binding) {
629
+ const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api });
630
+ if (bindingResolution.status === "not_bound") {
314
631
  return {
315
632
  status: "not_bound",
316
633
  repoRoot,
317
634
  appId: null,
318
635
  currentBranch: null,
319
- preferredBranch: null,
636
+ branchName: null,
320
637
  headCommitHash: null,
321
638
  worktreeClean: false,
322
639
  syncStatus: null,
@@ -328,19 +645,56 @@ async function collabRecordingPreflight(params) {
328
645
  hint: "Run `remix collab init` first."
329
646
  };
330
647
  }
648
+ if (bindingResolution.status === "missing_branch_binding") {
649
+ return {
650
+ status: "branch_binding_missing",
651
+ repoRoot,
652
+ appId: null,
653
+ currentBranch: bindingResolution.currentBranch,
654
+ branchName: bindingResolution.currentBranch,
655
+ headCommitHash: null,
656
+ worktreeClean: false,
657
+ syncStatus: null,
658
+ syncTargetCommitHash: null,
659
+ syncTargetCommitId: null,
660
+ reconcileTargetHeadCommitHash: null,
661
+ reconcileTargetHeadCommitId: null,
662
+ warnings: [],
663
+ hint: `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`
664
+ };
665
+ }
666
+ if (bindingResolution.status === "binding_conflict") {
667
+ return {
668
+ status: "metadata_conflict",
669
+ repoRoot,
670
+ appId: bindingResolution.binding.currentAppId,
671
+ currentBranch: bindingResolution.currentBranch,
672
+ branchName: bindingResolution.binding.branchName,
673
+ headCommitHash: null,
674
+ worktreeClean: false,
675
+ syncStatus: null,
676
+ syncTargetCommitHash: null,
677
+ syncTargetCommitId: null,
678
+ reconcileTargetHeadCommitHash: null,
679
+ reconcileTargetHeadCommitId: null,
680
+ warnings: [],
681
+ hint: `Local binding for ${bindingResolution.currentBranch ?? "(detached)"} points to app ${bindingResolution.binding.currentAppId}, but the server resolved lane ${bindingResolution.resolvedLane.laneId ?? "(unknown)"} / app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}. Repair the branch binding before recording work.`
682
+ };
683
+ }
684
+ const binding = bindingResolution.binding;
331
685
  const [currentBranch, headCommitHash, worktreeStatus] = await Promise.all([
332
686
  getCurrentBranch(repoRoot),
333
687
  getHeadCommitHash(repoRoot),
334
688
  getWorktreeStatus(repoRoot)
335
689
  ]);
336
- const preferredBranch = binding.preferredBranch ?? null;
690
+ const branchName = binding.branchName ?? null;
337
691
  if (!headCommitHash) {
338
692
  return {
339
693
  status: "missing_head",
340
694
  repoRoot,
341
695
  appId: binding.currentAppId,
342
696
  currentBranch,
343
- preferredBranch,
697
+ branchName,
344
698
  headCommitHash: null,
345
699
  worktreeClean: worktreeStatus.isClean,
346
700
  syncStatus: null,
@@ -352,13 +706,13 @@ async function collabRecordingPreflight(params) {
352
706
  hint: "Failed to resolve local HEAD commit."
353
707
  };
354
708
  }
355
- if (!params.allowBranchMismatch && !isPreferredBranchMatch(currentBranch, preferredBranch)) {
709
+ if (!params.allowBranchMismatch && !isBoundBranchMatch(currentBranch, branchName)) {
356
710
  return {
357
711
  status: "branch_mismatch",
358
712
  repoRoot,
359
713
  appId: binding.currentAppId,
360
714
  currentBranch,
361
- preferredBranch,
715
+ branchName,
362
716
  headCommitHash,
363
717
  worktreeClean: worktreeStatus.isClean,
364
718
  syncStatus: null,
@@ -367,9 +721,9 @@ async function collabRecordingPreflight(params) {
367
721
  reconcileTargetHeadCommitHash: null,
368
722
  reconcileTargetHeadCommitId: null,
369
723
  warnings: [],
370
- hint: buildPreferredBranchMismatchHint({
724
+ hint: buildBranchMismatchHint({
371
725
  currentBranch,
372
- preferredBranch
726
+ branchName
373
727
  })
374
728
  };
375
729
  }
@@ -387,7 +741,7 @@ async function collabRecordingPreflight(params) {
387
741
  repoRoot,
388
742
  appId: binding.currentAppId,
389
743
  currentBranch,
390
- preferredBranch,
744
+ branchName,
391
745
  headCommitHash,
392
746
  worktreeClean: worktreeStatus.isClean,
393
747
  syncStatus: sync.status,
@@ -405,7 +759,7 @@ async function collabRecordingPreflight(params) {
405
759
  repoRoot,
406
760
  appId: binding.currentAppId,
407
761
  currentBranch,
408
- preferredBranch,
762
+ branchName,
409
763
  headCommitHash,
410
764
  worktreeClean: worktreeStatus.isClean,
411
765
  syncStatus: sync.status,
@@ -430,7 +784,7 @@ async function collabRecordingPreflight(params) {
430
784
  repoRoot,
431
785
  appId: binding.currentAppId,
432
786
  currentBranch,
433
- preferredBranch,
787
+ branchName,
434
788
  headCommitHash,
435
789
  worktreeClean: worktreeStatus.isClean,
436
790
  syncStatus: sync.status,
@@ -448,7 +802,7 @@ async function collabRecordingPreflight(params) {
448
802
  repoRoot,
449
803
  appId: binding.currentAppId,
450
804
  currentBranch,
451
- preferredBranch,
805
+ branchName,
452
806
  headCommitHash,
453
807
  worktreeClean: worktreeStatus.isClean,
454
808
  syncStatus: sync.status,
@@ -465,7 +819,7 @@ async function collabRecordingPreflight(params) {
465
819
  repoRoot,
466
820
  appId: binding.currentAppId,
467
821
  currentBranch,
468
- preferredBranch,
822
+ branchName,
469
823
  headCommitHash,
470
824
  worktreeClean: worktreeStatus.isClean,
471
825
  syncStatus: sync.status,
@@ -697,7 +1051,11 @@ import os2 from "os";
697
1051
  import path2 from "path";
698
1052
  async function collabSync(params) {
699
1053
  const repoRoot = await findGitRoot(params.cwd);
700
- const binding = await readCollabBinding(repoRoot);
1054
+ const binding = await ensureActiveLaneBinding({
1055
+ repoRoot,
1056
+ api: params.api,
1057
+ operation: "`remix collab sync`"
1058
+ });
701
1059
  if (!binding) {
702
1060
  throw new RemixError("Repository is not bound to Remix.", {
703
1061
  exitCode: 2,
@@ -706,9 +1064,9 @@ async function collabSync(params) {
706
1064
  }
707
1065
  await ensureCleanWorktree(repoRoot);
708
1066
  const branch = await requireCurrentBranch(repoRoot);
709
- assertPreferredBranchMatch({
1067
+ assertBoundBranchMatch({
710
1068
  currentBranch: branch,
711
- preferredBranch: binding.preferredBranch,
1069
+ branchName: binding.branchName,
712
1070
  allowBranchMismatch: params.allowBranchMismatch,
713
1071
  operation: "`remix collab sync`"
714
1072
  });
@@ -785,9 +1143,9 @@ async function collabSync(params) {
785
1143
  });
786
1144
  await ensureCleanWorktree(lockedRepoRoot);
787
1145
  const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
788
- assertPreferredBranchMatch({
1146
+ assertBoundBranchMatch({
789
1147
  currentBranch: lockedBranch,
790
- preferredBranch: binding.preferredBranch,
1148
+ branchName: binding.branchName,
791
1149
  allowBranchMismatch: params.allowBranchMismatch,
792
1150
  operation: "`remix collab sync`"
793
1151
  });
@@ -820,6 +1178,12 @@ function assertSupportedRecordingPreflight(preflight) {
820
1178
  hint: preflight.hint
821
1179
  });
822
1180
  }
1181
+ if (preflight.status === "branch_binding_missing") {
1182
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
1183
+ exitCode: 2,
1184
+ hint: preflight.hint
1185
+ });
1186
+ }
823
1187
  if (preflight.status === "not_git_repo") {
824
1188
  throw new RemixError(preflight.hint || "Not inside a git repository.", {
825
1189
  exitCode: 2,
@@ -833,9 +1197,9 @@ function assertSupportedRecordingPreflight(preflight) {
833
1197
  });
834
1198
  }
835
1199
  if (preflight.status === "branch_mismatch") {
836
- assertPreferredBranchMatch({
1200
+ assertBoundBranchMatch({
837
1201
  currentBranch: preflight.currentBranch,
838
- preferredBranch: preflight.preferredBranch,
1202
+ branchName: preflight.branchName,
839
1203
  allowBranchMismatch: false,
840
1204
  operation: "`remix collab add`"
841
1205
  });
@@ -855,7 +1219,11 @@ function assertSupportedRecordingPreflight(preflight) {
855
1219
  }
856
1220
  async function collabAdd(params) {
857
1221
  const repoRoot = await findGitRoot(params.cwd);
858
- const binding = await readCollabBinding(repoRoot);
1222
+ const binding = await ensureActiveLaneBinding({
1223
+ repoRoot,
1224
+ api: params.api,
1225
+ operation: "`remix collab add`"
1226
+ });
859
1227
  if (!binding) {
860
1228
  throw new RemixError("Repository is not bound to Remix.", {
861
1229
  exitCode: 2,
@@ -876,9 +1244,9 @@ async function collabAdd(params) {
876
1244
  });
877
1245
  assertSupportedRecordingPreflight(preflight);
878
1246
  const branch = preflight.currentBranch;
879
- assertPreferredBranchMatch({
1247
+ assertBoundBranchMatch({
880
1248
  currentBranch: branch,
881
- preferredBranch: binding.preferredBranch,
1249
+ branchName: binding.branchName,
882
1250
  allowBranchMismatch: params.allowBranchMismatch,
883
1251
  operation: "`remix collab add`"
884
1252
  });
@@ -1051,6 +1419,7 @@ async function collabAdd(params) {
1051
1419
  });
1052
1420
  const resp = await params.api.createChangeStep(binding.currentAppId, {
1053
1421
  threadId: binding.threadId ?? void 0,
1422
+ collabLaneId: binding.laneId ?? void 0,
1054
1423
  prompt,
1055
1424
  assistantResponse: assistantResponse ?? void 0,
1056
1425
  diff,
@@ -1126,6 +1495,12 @@ function assertSupportedRecordingPreflight2(preflight) {
1126
1495
  hint: preflight.hint
1127
1496
  });
1128
1497
  }
1498
+ if (preflight.status === "branch_binding_missing") {
1499
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
1500
+ exitCode: 2,
1501
+ hint: preflight.hint
1502
+ });
1503
+ }
1129
1504
  if (preflight.status === "not_git_repo") {
1130
1505
  throw new RemixError(preflight.hint || "Not inside a git repository.", {
1131
1506
  exitCode: 2,
@@ -1139,9 +1514,9 @@ function assertSupportedRecordingPreflight2(preflight) {
1139
1514
  });
1140
1515
  }
1141
1516
  if (preflight.status === "branch_mismatch") {
1142
- assertPreferredBranchMatch({
1517
+ assertBoundBranchMatch({
1143
1518
  currentBranch: preflight.currentBranch,
1144
- preferredBranch: preflight.preferredBranch,
1519
+ branchName: preflight.branchName,
1145
1520
  allowBranchMismatch: false,
1146
1521
  operation: "`remix collab record-turn`"
1147
1522
  });
@@ -1161,7 +1536,11 @@ function assertSupportedRecordingPreflight2(preflight) {
1161
1536
  }
1162
1537
  async function collabRecordTurn(params) {
1163
1538
  const repoRoot = await findGitRoot(params.cwd);
1164
- const binding = await readCollabBinding(repoRoot);
1539
+ const binding = await ensureActiveLaneBinding({
1540
+ repoRoot,
1541
+ api: params.api,
1542
+ operation: "`remix collab record-turn`"
1543
+ });
1165
1544
  if (!binding) {
1166
1545
  throw new RemixError("Repository is not bound to Remix.", {
1167
1546
  exitCode: 2,
@@ -1193,9 +1572,9 @@ async function collabRecordTurn(params) {
1193
1572
  });
1194
1573
  }
1195
1574
  const branch = await getCurrentBranch(repoRoot);
1196
- assertPreferredBranchMatch({
1575
+ assertBoundBranchMatch({
1197
1576
  currentBranch: branch,
1198
- preferredBranch: binding.preferredBranch,
1577
+ branchName: binding.branchName,
1199
1578
  allowBranchMismatch: params.allowBranchMismatch,
1200
1579
  operation: "`remix collab record-turn`"
1201
1580
  });
@@ -1209,6 +1588,7 @@ async function collabRecordTurn(params) {
1209
1588
  });
1210
1589
  const resp = await params.api.createCollabTurn(binding.currentAppId, {
1211
1590
  threadId: binding.threadId ?? void 0,
1591
+ collabLaneId: binding.laneId ?? void 0,
1212
1592
  prompt,
1213
1593
  assistantResponse,
1214
1594
  actor: params.actor,
@@ -1221,7 +1601,8 @@ async function collabRecordTurn(params) {
1221
1601
  },
1222
1602
  idempotencyKey
1223
1603
  });
1224
- return unwrapResponseObject(resp, "collab turn");
1604
+ const turn = unwrapResponseObject(resp, "collab turn");
1605
+ return turn;
1225
1606
  }
1226
1607
 
1227
1608
  // src/application/collab/collabFinalizeTurn.ts
@@ -1231,7 +1612,11 @@ function collectWarnings(value) {
1231
1612
  }
1232
1613
  async function collabFinalizeTurn(params) {
1233
1614
  const repoRoot = await findGitRoot(params.cwd);
1234
- const binding = await readCollabBinding(repoRoot);
1615
+ const binding = await ensureActiveLaneBinding({
1616
+ repoRoot,
1617
+ api: params.api,
1618
+ operation: "`remix collab finalize-turn`"
1619
+ });
1235
1620
  if (!binding) {
1236
1621
  throw new RemixError("Repository is not bound to Remix.", {
1237
1622
  exitCode: 2,
@@ -1339,7 +1724,11 @@ async function collabApprove(params) {
1339
1724
  operation: "collabApproveSyncTarget"
1340
1725
  },
1341
1726
  async ({ repoRoot, warnings }) => {
1342
- const binding = await readCollabBinding(repoRoot);
1727
+ const binding = await ensureActiveLaneBinding({
1728
+ repoRoot,
1729
+ api: params.api,
1730
+ operation: "`remix collab approve --sync-target-repo`"
1731
+ });
1343
1732
  if (!binding) {
1344
1733
  throw new RemixError("Repository is not bound to Remix.", {
1345
1734
  exitCode: 2,
@@ -1348,9 +1737,9 @@ async function collabApprove(params) {
1348
1737
  }
1349
1738
  await ensureCleanWorktree(repoRoot, "`remix collab approve --sync-target-repo`");
1350
1739
  const branch = await requireCurrentBranch(repoRoot);
1351
- assertPreferredBranchMatch({
1740
+ assertBoundBranchMatch({
1352
1741
  currentBranch: branch,
1353
- preferredBranch: binding.preferredBranch,
1742
+ branchName: binding.branchName,
1354
1743
  allowBranchMismatch: params.allowBranchMismatch,
1355
1744
  operation: "`remix collab approve --sync-target-repo`"
1356
1745
  });
@@ -1433,10 +1822,6 @@ function isSubpath(parentPath, candidatePath) {
1433
1822
  const relative = path4.relative(parentPath, candidatePath);
1434
1823
  return relative === "" || !relative.startsWith("..") && !path4.isAbsolute(relative);
1435
1824
  }
1436
- function buildPreferredCheckoutBranch(appId) {
1437
- const normalized = appId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
1438
- return `remix/remix/${normalized || "app"}`;
1439
- }
1440
1825
  async function resolveCheckoutDestination(params) {
1441
1826
  if (params.outputDir?.trim()) {
1442
1827
  const preferredRepoRoot = path4.resolve(params.outputDir.trim());
@@ -1501,7 +1886,9 @@ async function materializeAppCheckout(params) {
1501
1886
  const bundle = await params.api.downloadAppBundle(params.appId);
1502
1887
  await fs4.writeFile(bundlePath, bundle.data);
1503
1888
  await cloneGitBundleToDirectory(bundlePath, repoRoot);
1504
- await checkoutLocalBranch(repoRoot, buildPreferredCheckoutBranch(params.appId));
1889
+ if (params.expectedBranchName?.trim()) {
1890
+ await checkoutLocalBranch(repoRoot, params.expectedBranchName.trim());
1891
+ }
1505
1892
  await ensureGitInfoExcludeEntries(repoRoot, [".remix/"]);
1506
1893
  } catch (err) {
1507
1894
  await fs4.rm(repoRoot, { recursive: true, force: true }).catch(() => {
@@ -1510,15 +1897,15 @@ async function materializeAppCheckout(params) {
1510
1897
  } finally {
1511
1898
  await fs4.rm(bundleTempDir, { recursive: true, force: true });
1512
1899
  }
1513
- const remoteUrl = normalizeGitRemote(await getRemoteOriginUrl(repoRoot));
1514
- const defaultBranch = await getDefaultBranch(repoRoot) ?? await getCurrentBranch(repoRoot) ?? null;
1515
- const preferredBranch = await getCurrentBranch(repoRoot) ?? buildPreferredCheckoutBranch(params.appId);
1900
+ const branchName = await getCurrentBranch(repoRoot) ?? params.expectedBranchName?.trim() ?? null;
1901
+ const remoteUrl = normalizeGitRemote(params.expectedRemoteUrl ?? await getRemoteOriginUrl(repoRoot));
1902
+ const defaultBranch = params.expectedDefaultBranch?.trim() ?? await getDefaultBranch(repoRoot) ?? branchName ?? null;
1516
1903
  const repoFingerprint = remoteUrl ? await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch }) : null;
1517
1904
  return {
1518
1905
  repoRoot,
1519
1906
  remoteUrl,
1520
1907
  defaultBranch,
1521
- preferredBranch,
1908
+ branchName,
1522
1909
  repoFingerprint
1523
1910
  };
1524
1911
  }
@@ -1533,29 +1920,55 @@ async function collabCheckout(params) {
1533
1920
  });
1534
1921
  }
1535
1922
  const app = await pollAppReady(params.api, appId);
1923
+ const collab = app.collab && typeof app.collab === "object" ? app.collab : null;
1924
+ const source = app.source && typeof app.source === "object" ? app.source : null;
1925
+ const authoritativeBranchName = typeof collab?.branchName === "string" && collab.branchName.trim() || typeof source?.branch === "string" && source.branch.trim() || typeof source?.defaultBranch === "string" && source.defaultBranch.trim() || null;
1926
+ const authoritativeRemoteUrl = typeof source?.remoteUrl === "string" && source.remoteUrl.trim() || null;
1927
+ const authoritativeDefaultBranch = typeof source?.defaultBranch === "string" && source.defaultBranch.trim() || null;
1536
1928
  const checkout = await materializeAppCheckout({
1537
1929
  api: params.api,
1538
1930
  cwd: params.cwd,
1539
1931
  appId: String(app.id),
1540
1932
  outputDir: params.outputDir ?? null,
1541
- defaultDirName: sanitizeCheckoutDirName(String(app.name || app.id))
1933
+ defaultDirName: sanitizeCheckoutDirName(String(app.name || app.id)),
1934
+ expectedBranchName: authoritativeBranchName,
1935
+ expectedRemoteUrl: authoritativeRemoteUrl,
1936
+ expectedDefaultBranch: authoritativeDefaultBranch
1542
1937
  });
1543
1938
  const upstreamAppId = String(app.forkedFromAppId ?? app.id);
1544
- const bindingPath = await writeCollabBinding(checkout.repoRoot, {
1939
+ const laneId = typeof collab?.laneId === "string" ? collab.laneId : null;
1940
+ const repoFingerprint = typeof source?.repoFingerprint === "string" ? source.repoFingerprint : checkout.repoFingerprint;
1941
+ const remoteUrl = typeof source?.remoteUrl === "string" ? source.remoteUrl : checkout.remoteUrl;
1942
+ const defaultBranch = typeof source?.defaultBranch === "string" ? source.defaultBranch : checkout.defaultBranch;
1943
+ const branchName = authoritativeBranchName ?? checkout.branchName;
1944
+ const authoritativeLane = await resolveProjectLaneIfAuthoritative(params.api, {
1545
1945
  projectId: String(app.projectId),
1946
+ repoFingerprint,
1947
+ remoteUrl,
1948
+ defaultBranch,
1949
+ branchName,
1546
1950
  currentAppId: String(app.id),
1547
- upstreamAppId,
1548
- threadId: app.threadId ? String(app.threadId) : null,
1549
- repoFingerprint: checkout.repoFingerprint,
1550
- remoteUrl: checkout.remoteUrl,
1551
- defaultBranch: checkout.defaultBranch,
1552
- preferredBranch: checkout.preferredBranch
1951
+ expectedUpstreamAppId: upstreamAppId,
1952
+ createIfMissing: true,
1953
+ seedAppId: String(app.id)
1954
+ });
1955
+ const bindingPath = await writeCollabBinding(checkout.repoRoot, {
1956
+ projectId: authoritativeLane?.projectId ?? String(app.projectId),
1957
+ currentAppId: authoritativeLane?.currentAppId ?? String(app.id),
1958
+ upstreamAppId: authoritativeLane?.upstreamAppId ?? upstreamAppId,
1959
+ threadId: authoritativeLane?.threadId ?? (app.threadId ? String(app.threadId) : null),
1960
+ repoFingerprint: authoritativeLane?.repoFingerprint ?? repoFingerprint,
1961
+ remoteUrl: authoritativeLane?.remoteUrl ?? remoteUrl,
1962
+ defaultBranch: authoritativeLane?.defaultBranch ?? defaultBranch,
1963
+ laneId: authoritativeLane?.laneId ?? laneId,
1964
+ branchName: authoritativeLane?.branchName ?? branchName,
1965
+ bindingMode: "lane"
1553
1966
  });
1554
1967
  return {
1555
- appId: String(app.id),
1556
- dashboardUrl: buildDashboardAppUrl(String(app.id)),
1557
- projectId: String(app.projectId),
1558
- upstreamAppId,
1968
+ appId: authoritativeLane?.currentAppId ?? String(app.id),
1969
+ dashboardUrl: buildDashboardAppUrl(authoritativeLane?.currentAppId ?? String(app.id)),
1970
+ projectId: authoritativeLane?.projectId ?? String(app.projectId),
1971
+ upstreamAppId: authoritativeLane?.upstreamAppId ?? upstreamAppId,
1559
1972
  bindingPath,
1560
1973
  repoRoot: checkout.repoRoot
1561
1974
  };
@@ -1577,14 +1990,26 @@ async function resolveQueueAppId(params) {
1577
1990
  });
1578
1991
  }
1579
1992
  const repoRoot = await findGitRoot(params.cwd);
1580
- const binding = await readCollabBinding(repoRoot);
1581
- if (!binding) {
1993
+ const bindingResolution = await resolveActiveLaneBinding({ repoRoot });
1994
+ if (bindingResolution.status === "not_bound") {
1582
1995
  throw new RemixError("Repository is not bound to Remix.", {
1583
1996
  exitCode: 2,
1584
1997
  hint: "Bind the repository first or pass `appId` explicitly for the app-scoped merge-request queue."
1585
1998
  });
1586
1999
  }
1587
- return binding.currentAppId;
2000
+ if (bindingResolution.status === "missing_branch_binding") {
2001
+ throw new RemixError("Current branch is not yet bound to a Remix lane.", {
2002
+ exitCode: 2,
2003
+ hint: `Switch back to a bound branch or create a lane by recording work on ${bindingResolution.currentBranch ?? "this branch"}.`
2004
+ });
2005
+ }
2006
+ if (bindingResolution.status === "binding_conflict") {
2007
+ throw new RemixError("Current branch binding conflicts with the server-resolved Remix lane.", {
2008
+ exitCode: 2,
2009
+ hint: `Local app ${bindingResolution.binding.currentAppId}; server app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}.`
2010
+ });
2011
+ }
2012
+ return bindingResolution.binding.currentAppId;
1588
2013
  }
1589
2014
  async function collabListMergeRequests(params) {
1590
2015
  const appId = await resolveQueueAppId({
@@ -1615,12 +2040,32 @@ async function collabListMergeRequests(params) {
1615
2040
  async function resolveScopeTarget(params) {
1616
2041
  if (params.targetId?.trim()) return params.targetId.trim();
1617
2042
  const repoRoot = await findGitRoot(params.cwd);
1618
- const binding = await readCollabBinding(repoRoot);
1619
- if (!binding) {
2043
+ const bindingResolution = await resolveActiveLaneBinding({ repoRoot, api: params.api });
2044
+ if (bindingResolution.status === "not_bound") {
1620
2045
  throw new RemixError("Repository is not bound to Remix and no explicit target id was provided.", { exitCode: 2 });
1621
2046
  }
1622
- if (params.scope === "project") return binding.projectId;
2047
+ if (bindingResolution.status === "missing_branch_binding") {
2048
+ throw new RemixError("Current branch is not yet bound to a Remix lane and no explicit target id was provided.", {
2049
+ exitCode: 2
2050
+ });
2051
+ }
2052
+ if (bindingResolution.status === "binding_conflict") {
2053
+ throw new RemixError("Current branch binding conflicts with the server-resolved Remix lane.", {
2054
+ exitCode: 2,
2055
+ hint: `Local app ${bindingResolution.binding.currentAppId}; server app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"}.`
2056
+ });
2057
+ }
2058
+ const binding = bindingResolution.binding;
2059
+ if (params.scope === "project") {
2060
+ if (!binding.projectId) {
2061
+ throw new RemixError("Could not resolve the project for the current repository binding.", { exitCode: 2 });
2062
+ }
2063
+ return binding.projectId;
2064
+ }
1623
2065
  if (params.scope === "app") return binding.currentAppId;
2066
+ if (!binding.projectId) {
2067
+ throw new RemixError("Could not resolve the project for the current repository binding.", { exitCode: 2 });
2068
+ }
1624
2069
  const project = unwrapResponseObject(await params.api.getProject(binding.projectId), "project");
1625
2070
  const organizationId = typeof project.organizationId === "string" ? project.organizationId : null;
1626
2071
  if (!organizationId) {
@@ -1758,13 +2203,28 @@ async function collabInit(params) {
1758
2203
  const remoteUrl = normalizeGitRemote(await getRemoteOriginUrl(repoRoot));
1759
2204
  const currentBranch = await getCurrentBranch(repoRoot);
1760
2205
  const defaultBranch = await getDefaultBranch(repoRoot) ?? currentBranch;
1761
- const preferredBranch = currentBranch ?? defaultBranch ?? null;
2206
+ const branchName = currentBranch ?? defaultBranch ?? null;
1762
2207
  const repoFingerprint = await buildRepoFingerprint({ gitRoot: repoRoot, remoteUrl, defaultBranch });
1763
2208
  const repoSnapshot = await captureRepoSnapshot(repoRoot);
2209
+ if (params.forceNew) {
2210
+ const bindingResp = await params.api.resolveProjectBinding({
2211
+ repoFingerprint,
2212
+ remoteUrl: remoteUrl ?? void 0,
2213
+ branchName: branchName ?? void 0
2214
+ });
2215
+ const existing = bindingResp?.responseObject;
2216
+ if (existing?.projectId && existing?.appId) {
2217
+ throw new RemixError("`remix collab init --force-new` is not allowed for repositories already known to Remix.", {
2218
+ exitCode: 2,
2219
+ hint: "This repository already resolves to an existing Remix lineage. Run `remix collab init` without `--force-new`, or use an explicit checkout/remix flow instead of creating a duplicate imported app."
2220
+ });
2221
+ }
2222
+ }
1764
2223
  if (!params.forceNew) {
1765
2224
  const bindingResp = await params.api.resolveProjectBinding({
1766
2225
  repoFingerprint,
1767
- remoteUrl: remoteUrl ?? void 0
2226
+ remoteUrl: remoteUrl ?? void 0,
2227
+ branchName: branchName ?? void 0
1768
2228
  });
1769
2229
  const existing = bindingResp?.responseObject;
1770
2230
  if (existing?.projectId && existing?.appId) {
@@ -1772,25 +2232,55 @@ async function collabInit(params) {
1772
2232
  operation: "`remix collab init`",
1773
2233
  recoveryHint: "The repository changed while the local binding was being initialized. Review the local changes and rerun `remix collab init`."
1774
2234
  });
2235
+ const initialProjectId = String(existing.projectId);
2236
+ const initialCurrentAppId = String(existing.appId);
2237
+ const initialUpstreamAppId = String(existing.upstreamAppId ?? existing.appId);
2238
+ const initialThreadId = existing.threadId ? String(existing.threadId) : null;
1775
2239
  const bindingPath2 = await writeCollabBinding(repoRoot, {
1776
- projectId: String(existing.projectId),
1777
- currentAppId: String(existing.appId),
1778
- upstreamAppId: String(existing.upstreamAppId ?? existing.appId),
1779
- threadId: existing.threadId ? String(existing.threadId) : null,
2240
+ projectId: initialProjectId,
2241
+ currentAppId: initialCurrentAppId,
2242
+ upstreamAppId: initialUpstreamAppId,
2243
+ threadId: initialThreadId,
1780
2244
  repoFingerprint,
1781
2245
  remoteUrl,
1782
2246
  defaultBranch: defaultBranch ?? null,
1783
- preferredBranch
2247
+ laneId: null,
2248
+ branchName,
2249
+ bindingMode: "lane"
1784
2250
  });
2251
+ let boundProjectId2 = initialProjectId;
2252
+ let boundCurrentAppId2 = initialCurrentAppId;
2253
+ let boundUpstreamAppId2 = initialUpstreamAppId;
2254
+ let boundThreadId2 = initialThreadId;
2255
+ let finalWarnings = [...warnings];
2256
+ if (branchName) {
2257
+ const provisioned = await provisionActiveLaneBinding({
2258
+ repoRoot,
2259
+ api: params.api,
2260
+ operation: "`remix collab init`"
2261
+ });
2262
+ if (provisioned.binding) {
2263
+ boundProjectId2 = provisioned.binding.projectId ?? boundProjectId2;
2264
+ boundCurrentAppId2 = provisioned.binding.currentAppId;
2265
+ boundUpstreamAppId2 = provisioned.binding.upstreamAppId;
2266
+ boundThreadId2 = provisioned.binding.threadId;
2267
+ }
2268
+ finalWarnings = [...finalWarnings, ...provisioned.warnings];
2269
+ }
2270
+ if (boundCurrentAppId2) {
2271
+ const readyApp = await pollAppReady(params.api, boundCurrentAppId2);
2272
+ boundProjectId2 = String(readyApp.projectId ?? boundProjectId2);
2273
+ boundThreadId2 = readyApp.threadId ? String(readyApp.threadId) : boundThreadId2;
2274
+ }
1785
2275
  return {
1786
2276
  reused: true,
1787
- projectId: String(existing.projectId),
1788
- appId: String(existing.appId),
1789
- dashboardUrl: buildDashboardAppUrl(String(existing.appId)),
1790
- upstreamAppId: String(existing.upstreamAppId ?? existing.appId),
2277
+ projectId: boundProjectId2,
2278
+ appId: boundCurrentAppId2,
2279
+ dashboardUrl: buildDashboardAppUrl(boundCurrentAppId2),
2280
+ upstreamAppId: boundUpstreamAppId2,
1791
2281
  bindingPath: bindingPath2,
1792
2282
  repoRoot,
1793
- ...warnings.length > 0 ? { warnings } : {}
2283
+ ...finalWarnings.length > 0 ? { warnings: finalWarnings } : {}
1794
2284
  };
1795
2285
  }
1796
2286
  }
@@ -1817,6 +2307,7 @@ async function collabInit(params) {
1817
2307
  path: params.path?.trim() || void 0,
1818
2308
  platform: "generic",
1819
2309
  isPublic: false,
2310
+ branch: currentBranch ?? void 0,
1820
2311
  remoteUrl: remoteUrl ?? void 0,
1821
2312
  defaultBranch: defaultBranch ?? void 0,
1822
2313
  repoFingerprint,
@@ -1824,31 +2315,65 @@ async function collabInit(params) {
1824
2315
  });
1825
2316
  const imported = unwrapResponseObject(importResp, "import");
1826
2317
  const app = await pollAppReady(params.api, String(imported.appId));
2318
+ let boundProjectId = String(app.projectId);
2319
+ let boundCurrentAppId = String(app.id);
2320
+ let boundUpstreamAppId = String(app.id);
2321
+ let boundThreadId = app.threadId ? String(app.threadId) : null;
2322
+ let boundLaneId = null;
2323
+ if (branchName) {
2324
+ const laneResp = defaultBranch && branchName !== defaultBranch ? await params.api.bootstrapFreshProjectLane({
2325
+ projectId: boundProjectId,
2326
+ repoFingerprint,
2327
+ remoteUrl: remoteUrl ?? void 0,
2328
+ defaultBranch: defaultBranch ?? void 0,
2329
+ branchName,
2330
+ seedAppId: String(app.id)
2331
+ }) : await params.api.ensureProjectLaneBinding({
2332
+ projectId: boundProjectId,
2333
+ repoFingerprint,
2334
+ remoteUrl: remoteUrl ?? void 0,
2335
+ defaultBranch: defaultBranch ?? void 0,
2336
+ branchName,
2337
+ seedAppId: String(app.id)
2338
+ });
2339
+ const lane = unwrapResponseObject(laneResp, "project lane binding");
2340
+ boundProjectId = typeof lane.projectId === "string" && lane.projectId ? lane.projectId : boundProjectId;
2341
+ boundCurrentAppId = typeof lane.currentAppId === "string" && lane.currentAppId ? lane.currentAppId : boundCurrentAppId;
2342
+ boundUpstreamAppId = typeof lane.upstreamAppId === "string" && lane.upstreamAppId ? lane.upstreamAppId : boundUpstreamAppId;
2343
+ boundThreadId = typeof lane.threadId === "string" && lane.threadId ? lane.threadId : boundThreadId;
2344
+ boundLaneId = typeof lane.laneId === "string" && lane.laneId ? lane.laneId : null;
2345
+ }
2346
+ if (boundCurrentAppId) {
2347
+ const readyApp = await pollAppReady(params.api, boundCurrentAppId);
2348
+ boundProjectId = String(readyApp.projectId ?? boundProjectId);
2349
+ boundThreadId = readyApp.threadId ? String(readyApp.threadId) : boundThreadId;
2350
+ }
1827
2351
  await assertRepoSnapshotUnchanged(repoRoot, repoSnapshot, {
1828
2352
  operation: "`remix collab init`",
1829
2353
  recoveryHint: "The repository changed before the Remix binding was written. Review the local changes and rerun `remix collab init`."
1830
2354
  });
1831
2355
  const bindingPath = await writeCollabBinding(repoRoot, {
1832
- projectId: String(app.projectId),
1833
- currentAppId: String(app.id),
1834
- upstreamAppId: String(app.id),
1835
- threadId: app.threadId ? String(app.threadId) : null,
2356
+ projectId: boundProjectId,
2357
+ currentAppId: boundCurrentAppId,
2358
+ upstreamAppId: boundUpstreamAppId,
2359
+ threadId: boundThreadId,
1836
2360
  repoFingerprint,
1837
2361
  remoteUrl,
1838
2362
  defaultBranch: defaultBranch ?? null,
1839
- preferredBranch
2363
+ laneId: boundLaneId,
2364
+ branchName,
2365
+ bindingMode: "lane"
1840
2366
  });
1841
2367
  return {
1842
2368
  reused: false,
1843
- projectId: String(app.projectId),
1844
- appId: String(app.id),
1845
- dashboardUrl: buildDashboardAppUrl(String(app.id)),
1846
- upstreamAppId: String(app.id),
2369
+ projectId: boundProjectId,
2370
+ appId: boundCurrentAppId,
2371
+ dashboardUrl: buildDashboardAppUrl(boundCurrentAppId),
2372
+ upstreamAppId: boundUpstreamAppId,
1847
2373
  bindingPath,
1848
2374
  repoRoot,
1849
2375
  remoteUrl,
1850
2376
  defaultBranch,
1851
- preferredBranch,
1852
2377
  ...warnings.length > 0 ? { warnings } : {}
1853
2378
  };
1854
2379
  }
@@ -1903,7 +2428,11 @@ import os4 from "os";
1903
2428
  import path6 from "path";
1904
2429
  async function collabReconcile(params) {
1905
2430
  const repoRoot = await findGitRoot(params.cwd);
1906
- const binding = await readCollabBinding(repoRoot);
2431
+ const binding = await ensureActiveLaneBinding({
2432
+ repoRoot,
2433
+ api: params.api,
2434
+ operation: "`remix collab reconcile`"
2435
+ });
1907
2436
  if (!binding) {
1908
2437
  throw new RemixError("Repository is not bound to Remix.", {
1909
2438
  exitCode: 2,
@@ -1912,9 +2441,9 @@ async function collabReconcile(params) {
1912
2441
  }
1913
2442
  await ensureCleanWorktree(repoRoot, "`remix collab reconcile`");
1914
2443
  const branch = await requireCurrentBranch(repoRoot);
1915
- assertPreferredBranchMatch({
2444
+ assertBoundBranchMatch({
1916
2445
  currentBranch: branch,
1917
- preferredBranch: binding.preferredBranch,
2446
+ branchName: binding.branchName,
1918
2447
  allowBranchMismatch: params.allowBranchMismatch,
1919
2448
  operation: "`remix collab reconcile`"
1920
2449
  });
@@ -2023,9 +2552,9 @@ async function collabReconcile(params) {
2023
2552
  });
2024
2553
  await ensureCleanWorktree(lockedRepoRoot, "`remix collab reconcile`");
2025
2554
  const lockedBranch = await requireCurrentBranch(lockedRepoRoot);
2026
- assertPreferredBranchMatch({
2555
+ assertBoundBranchMatch({
2027
2556
  currentBranch: lockedBranch,
2028
- preferredBranch: binding.preferredBranch,
2557
+ branchName: binding.branchName,
2029
2558
  allowBranchMismatch: params.allowBranchMismatch,
2030
2559
  operation: "`remix collab reconcile`"
2031
2560
  });
@@ -2084,31 +2613,65 @@ async function collabRemix(params) {
2084
2613
  hint: "Pass the source app id to remix."
2085
2614
  });
2086
2615
  }
2087
- const forkResp = await params.api.forkApp(sourceAppId, { name: params.name?.trim() || void 0, platform: "generic" });
2616
+ const sourceApp = await pollAppReady(params.api, sourceAppId);
2617
+ const sourceCollab = sourceApp.collab && typeof sourceApp.collab === "object" ? sourceApp.collab : null;
2618
+ const sourceSource = sourceApp.source && typeof sourceApp.source === "object" ? sourceApp.source : null;
2619
+ const sourceBranchName = typeof sourceCollab?.branchName === "string" && sourceCollab.branchName.trim() || typeof sourceSource?.branch === "string" && sourceSource.branch.trim() || typeof sourceSource?.defaultBranch === "string" && sourceSource.defaultBranch.trim() || void 0;
2620
+ const forkResp = await params.api.forkApp(sourceAppId, {
2621
+ name: params.name?.trim() || void 0,
2622
+ platform: "generic",
2623
+ branchName: sourceBranchName
2624
+ });
2088
2625
  const forked = unwrapResponseObject(forkResp, "fork");
2089
2626
  const app = await pollAppReady(params.api, String(forked.id));
2627
+ const collab = app.collab && typeof app.collab === "object" ? app.collab : null;
2628
+ const source = app.source && typeof app.source === "object" ? app.source : null;
2629
+ const authoritativeBranchName = typeof collab?.branchName === "string" && collab.branchName.trim() || typeof source?.branch === "string" && source.branch.trim() || typeof source?.defaultBranch === "string" && source.defaultBranch.trim() || sourceBranchName || null;
2630
+ const authoritativeRemoteUrl = typeof source?.remoteUrl === "string" && source.remoteUrl.trim() || null;
2631
+ const authoritativeDefaultBranch = typeof source?.defaultBranch === "string" && source.defaultBranch.trim() || null;
2090
2632
  const checkout = await materializeAppCheckout({
2091
2633
  api: params.api,
2092
2634
  cwd: params.cwd,
2093
2635
  appId: String(app.id),
2094
2636
  outputDir: params.outputDir ?? null,
2095
- defaultDirName: sanitizeCheckoutDirName(String(params.name?.trim() || app.name || app.id))
2637
+ defaultDirName: sanitizeCheckoutDirName(String(params.name?.trim() || app.name || app.id)),
2638
+ expectedBranchName: authoritativeBranchName,
2639
+ expectedRemoteUrl: authoritativeRemoteUrl,
2640
+ expectedDefaultBranch: authoritativeDefaultBranch
2096
2641
  });
2097
- const bindingPath = await writeCollabBinding(checkout.repoRoot, {
2642
+ const laneId = typeof collab?.laneId === "string" ? collab.laneId : null;
2643
+ const repoFingerprint = typeof source?.repoFingerprint === "string" ? source.repoFingerprint : checkout.repoFingerprint;
2644
+ const remoteUrl = typeof source?.remoteUrl === "string" ? source.remoteUrl : checkout.remoteUrl;
2645
+ const defaultBranch = typeof source?.defaultBranch === "string" ? source.defaultBranch : checkout.defaultBranch;
2646
+ const branchName = authoritativeBranchName ?? checkout.branchName;
2647
+ const authoritativeLane = await resolveProjectLaneIfAuthoritative(params.api, {
2098
2648
  projectId: String(app.projectId),
2649
+ repoFingerprint,
2650
+ remoteUrl,
2651
+ defaultBranch,
2652
+ branchName,
2099
2653
  currentAppId: String(app.id),
2100
- upstreamAppId: String(app.forkedFromAppId ?? sourceAppId),
2101
- threadId: app.threadId ? String(app.threadId) : null,
2102
- repoFingerprint: checkout.repoFingerprint,
2103
- remoteUrl: checkout.remoteUrl,
2104
- defaultBranch: checkout.defaultBranch,
2105
- preferredBranch: checkout.preferredBranch
2654
+ expectedUpstreamAppId: String(app.forkedFromAppId ?? sourceAppId),
2655
+ createIfMissing: true,
2656
+ seedAppId: String(app.id)
2657
+ });
2658
+ const bindingPath = await writeCollabBinding(checkout.repoRoot, {
2659
+ projectId: authoritativeLane?.projectId ?? String(app.projectId),
2660
+ currentAppId: authoritativeLane?.currentAppId ?? String(app.id),
2661
+ upstreamAppId: authoritativeLane?.upstreamAppId ?? String(app.forkedFromAppId ?? sourceAppId),
2662
+ threadId: authoritativeLane?.threadId ?? (app.threadId ? String(app.threadId) : null),
2663
+ repoFingerprint: authoritativeLane?.repoFingerprint ?? repoFingerprint,
2664
+ remoteUrl: authoritativeLane?.remoteUrl ?? remoteUrl,
2665
+ defaultBranch: authoritativeLane?.defaultBranch ?? defaultBranch,
2666
+ laneId: authoritativeLane?.laneId ?? laneId,
2667
+ branchName: authoritativeLane?.branchName ?? branchName,
2668
+ bindingMode: "lane"
2106
2669
  });
2107
2670
  return {
2108
- appId: String(app.id),
2109
- dashboardUrl: buildDashboardAppUrl(String(app.id)),
2110
- projectId: String(app.projectId),
2111
- upstreamAppId: String(app.forkedFromAppId ?? sourceAppId),
2671
+ appId: authoritativeLane?.currentAppId ?? String(app.id),
2672
+ dashboardUrl: buildDashboardAppUrl(authoritativeLane?.currentAppId ?? String(app.id)),
2673
+ projectId: authoritativeLane?.projectId ?? String(app.projectId),
2674
+ upstreamAppId: authoritativeLane?.upstreamAppId ?? String(app.forkedFromAppId ?? sourceAppId),
2112
2675
  bindingPath,
2113
2676
  repoRoot: checkout.repoRoot
2114
2677
  };
@@ -2117,7 +2680,11 @@ async function collabRemix(params) {
2117
2680
  // src/application/collab/collabRequestMerge.ts
2118
2681
  async function collabRequestMerge(params) {
2119
2682
  const repoRoot = await findGitRoot(params.cwd);
2120
- const binding = await readCollabBinding(repoRoot);
2683
+ const binding = await ensureActiveLaneBinding({
2684
+ repoRoot,
2685
+ api: params.api,
2686
+ operation: "`remix collab request-merge`"
2687
+ });
2121
2688
  if (!binding) throw new RemixError("Repository is not bound to Remix.", { exitCode: 2 });
2122
2689
  const resp = await params.api.openMergeRequest(binding.currentAppId);
2123
2690
  return unwrapResponseObject(resp, "merge request");
@@ -2152,7 +2719,9 @@ function createBaseStatus() {
2152
2719
  repoFingerprint: null,
2153
2720
  remoteUrl: null,
2154
2721
  defaultBranch: null,
2155
- preferredBranch: null
2722
+ laneId: null,
2723
+ branchName: null,
2724
+ bindingMode: null
2156
2725
  },
2157
2726
  remote: {
2158
2727
  checked: false,
@@ -2216,12 +2785,13 @@ async function collabStatus(params) {
2216
2785
  }
2217
2786
  status.repo.isGitRepo = true;
2218
2787
  status.repo.repoRoot = repoRoot;
2219
- const [branch, headCommitHash, worktreeStatus, binding] = await Promise.all([
2788
+ const [branch, initialHeadCommitHash, worktreeStatus, bindingResolution] = await Promise.all([
2220
2789
  getCurrentBranch(repoRoot),
2221
2790
  getHeadCommitHash(repoRoot),
2222
2791
  getWorktreeStatus(repoRoot),
2223
- readCollabBinding(repoRoot)
2792
+ resolveActiveLaneBinding({ repoRoot, api: params.api ?? void 0 })
2224
2793
  ]);
2794
+ let headCommitHash = initialHeadCommitHash;
2225
2795
  status.repo.branch = branch;
2226
2796
  status.repo.branchMismatch = false;
2227
2797
  status.repo.headCommitHash = headCommitHash;
@@ -2235,13 +2805,36 @@ async function collabStatus(params) {
2235
2805
  if (!status.repo.worktree.isClean) addWarning(status, "Working tree has local changes.");
2236
2806
  if (!branch) addWarning(status, "Repository is in a detached HEAD state.");
2237
2807
  if (!headCommitHash) addWarning(status, "Failed to resolve local HEAD commit.");
2238
- if (!binding) {
2808
+ if (bindingResolution.status === "not_bound") {
2239
2809
  status.binding.path = null;
2240
2810
  addBlockedReason(status.sync, "not_bound");
2241
2811
  addBlockedReason(status.reconcile, "not_bound");
2242
2812
  status.recommendedAction = "init";
2243
2813
  return status;
2244
2814
  }
2815
+ if (bindingResolution.status === "missing_branch_binding") {
2816
+ status.binding = {
2817
+ isBound: true,
2818
+ path: getCollabBindingPath(repoRoot),
2819
+ projectId: bindingResolution.projectId,
2820
+ currentAppId: null,
2821
+ upstreamAppId: bindingResolution.upstreamAppId,
2822
+ isRemix: null,
2823
+ threadId: bindingResolution.threadId,
2824
+ repoFingerprint: bindingResolution.repoFingerprint,
2825
+ remoteUrl: bindingResolution.remoteUrl,
2826
+ defaultBranch: bindingResolution.defaultBranch,
2827
+ laneId: null,
2828
+ branchName: bindingResolution.currentBranch,
2829
+ bindingMode: "lane"
2830
+ };
2831
+ addBlockedReason(status.sync, "branch_binding_missing");
2832
+ addBlockedReason(status.reconcile, "branch_binding_missing");
2833
+ addWarning(status, `Current branch ${bindingResolution.currentBranch ?? "(detached)"} is not yet bound to a Remix lane.`);
2834
+ status.recommendedAction = "no_action";
2835
+ return status;
2836
+ }
2837
+ const binding = bindingResolution.binding;
2245
2838
  status.binding = {
2246
2839
  isBound: true,
2247
2840
  path: getCollabBindingPath(repoRoot),
@@ -2253,13 +2846,21 @@ async function collabStatus(params) {
2253
2846
  repoFingerprint: binding.repoFingerprint,
2254
2847
  remoteUrl: binding.remoteUrl,
2255
2848
  defaultBranch: binding.defaultBranch,
2256
- preferredBranch: binding.preferredBranch
2849
+ laneId: binding.laneId,
2850
+ branchName: binding.branchName,
2851
+ bindingMode: binding.bindingMode
2257
2852
  };
2258
- status.repo.branchMismatch = !isPreferredBranchMatch(branch, binding.preferredBranch);
2853
+ status.repo.branchMismatch = !isBoundBranchMatch(branch, binding.branchName);
2259
2854
  if (status.repo.branchMismatch) {
2260
2855
  addWarning(
2261
2856
  status,
2262
- `Current branch ${branch ?? "(detached)"} does not match preferred branch ${binding.preferredBranch ?? "(unset)"}.`
2857
+ `Current branch ${branch ?? "(detached)"} does not match bound branch ${binding.branchName ?? "(unset)"}.`
2858
+ );
2859
+ }
2860
+ if (bindingResolution.status === "binding_conflict") {
2861
+ addWarning(
2862
+ status,
2863
+ `Local binding app ${binding.currentAppId} conflicts with server-resolved app ${bindingResolution.resolvedLane.currentAppId ?? "(unknown)"} for branch ${bindingResolution.currentBranch ?? "(detached)"}.`
2263
2864
  );
2264
2865
  }
2265
2866
  if (!params.api) {
@@ -2307,8 +2908,14 @@ async function collabStatus(params) {
2307
2908
  const syncResp = syncResult.value;
2308
2909
  if (syncResp) {
2309
2910
  const sync = unwrapResponseObject(syncResp, "sync result");
2911
+ const latestHeadCommitHash = sync.targetCommitHash && sync.status !== "up_to_date" ? await getHeadCommitHash(repoRoot) : headCommitHash;
2912
+ if (latestHeadCommitHash) {
2913
+ headCommitHash = latestHeadCommitHash;
2914
+ status.repo.headCommitHash = latestHeadCommitHash;
2915
+ }
2916
+ const normalizedSyncStatus = headCommitHash && sync.targetCommitHash && headCommitHash === sync.targetCommitHash ? "up_to_date" : sync.status;
2310
2917
  status.sync.checked = true;
2311
- status.sync.status = sync.status;
2918
+ status.sync.status = normalizedSyncStatus;
2312
2919
  status.sync.warnings = sync.warnings;
2313
2920
  status.sync.targetCommitHash = sync.targetCommitHash;
2314
2921
  status.sync.targetCommitId = sync.targetCommitId;
@@ -2316,9 +2923,9 @@ async function collabStatus(params) {
2316
2923
  if (!status.repo.worktree.isClean) addBlockedReason(status.sync, "dirty_worktree");
2317
2924
  if (!branch) addBlockedReason(status.sync, "detached_head");
2318
2925
  if (status.repo.branchMismatch) addBlockedReason(status.sync, "branch_mismatch");
2319
- if (sync.status === "conflict_risk") addBlockedReason(status.sync, "metadata_conflict");
2926
+ if (normalizedSyncStatus === "conflict_risk") addBlockedReason(status.sync, "metadata_conflict");
2320
2927
  status.sync.canApply = status.sync.status === "ready_to_fast_forward" && status.repo.worktree.isClean && Boolean(branch) && !status.sync.blockedReasons.includes("metadata_conflict");
2321
- if (sync.status === "conflict_risk") {
2928
+ if (normalizedSyncStatus === "conflict_risk") {
2322
2929
  status.reconcile.checked = true;
2323
2930
  status.reconcile.status = "metadata_conflict";
2324
2931
  status.reconcile.warnings = sync.warnings;
@@ -2328,7 +2935,7 @@ async function collabStatus(params) {
2328
2935
  if (!status.repo.worktree.isClean) addBlockedReason(status.reconcile, "dirty_worktree");
2329
2936
  if (!branch) addBlockedReason(status.reconcile, "detached_head");
2330
2937
  if (status.repo.branchMismatch) addBlockedReason(status.reconcile, "branch_mismatch");
2331
- } else if (sync.status === "base_unknown") {
2938
+ } else if (normalizedSyncStatus === "base_unknown") {
2332
2939
  try {
2333
2940
  const preflightResp = await params.api.preflightAppReconcile(binding.currentAppId, {
2334
2941
  localHeadCommitHash: headCommitHash,
@@ -2385,7 +2992,11 @@ async function collabStatus(params) {
2385
2992
  // src/application/collab/collabSyncUpstream.ts
2386
2993
  async function collabSyncUpstream(params) {
2387
2994
  const repoRoot = await findGitRoot(params.cwd);
2388
- const binding = await readCollabBinding(repoRoot);
2995
+ const binding = await ensureActiveLaneBinding({
2996
+ repoRoot,
2997
+ api: params.api,
2998
+ operation: "`remix collab sync-upstream`"
2999
+ });
2389
3000
  if (!binding) {
2390
3001
  throw new RemixError("Repository is not bound to Remix.", {
2391
3002
  exitCode: 2,