borgmcp 5.0.0 → 5.0.3

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.
@@ -414,6 +414,27 @@ export interface AssimilateDeps {
414
414
 
415
415
  }
416
416
 
417
+ export type AuthorityResolutionDeps = Pick<
418
+ AssimilateDeps,
419
+ | 'connectServer'
420
+ | 'cwd'
421
+ | 'defaultAuthority'
422
+ | 'detectLocalServer'
423
+ | 'ensureLocalServerInstalled'
424
+ | 'getActiveCube'
425
+ | 'getHostname'
426
+ | 'hasPersistedActiveCube'
427
+ | 'isTTY'
428
+ | 'peekPendingServerEnrollment'
429
+ | 'preparePrivateRoot'
430
+ | 'prompt'
431
+ | 'promptSecret'
432
+ | 'resumePendingServerEnrollment'
433
+ | 'resumeServerEnrollment'
434
+ | 'runSync'
435
+ | 'stderr'
436
+ >;
437
+
417
438
  type AssimilationAuthority =
418
439
  | { kind: 'server'; apiUrl: string };
419
440
 
@@ -429,7 +450,7 @@ function strictAffirmative(answer: string): boolean {
429
450
 
430
451
  async function selectAssimilationAuthority(
431
452
  flags: AssimilateFlags,
432
- deps: AssimilateDeps,
453
+ deps: AuthorityResolutionDeps,
433
454
  mode: 'assimilate' | 'cube-init',
434
455
  ): Promise<AssimilationAuthority | null> {
435
456
  if (flags.server !== undefined) {
@@ -513,7 +534,7 @@ function localAssimilateCliCommand(apiUrl: string, cli: BorgCli): string {
513
534
  }
514
535
 
515
536
  function reportServerFailure(
516
- deps: AssimilateDeps,
537
+ deps: Pick<AssimilateDeps, 'stderr'>,
517
538
  apiUrl: string,
518
539
  error: unknown,
519
540
  enroll = false,
@@ -698,7 +719,7 @@ function resetLocalSeatCommand(apiUrl: string): string {
698
719
  // Pin-matched terminal session diagnosis. This is intentionally output-only:
699
720
  // only the explicit offline reset command may clear the saved local seat.
700
721
  function diagnoseSessionTermination(
701
- deps: AssimilateDeps,
722
+ deps: Pick<AssimilateDeps, 'stderr'>,
702
723
  apiUrl: string,
703
724
  outcome: 'revoked' | 'superseded',
704
725
  mode: 'assimilate' | 'cube-init' = 'assimilate',
@@ -716,27 +737,40 @@ function diagnoseSessionTermination(
716
737
  return 1;
717
738
  }
718
739
 
719
- export async function runAssimilate(
740
+ export type AssimilationPhaseOutcome<T> =
741
+ | { kind: 'continue'; value: T }
742
+ | { kind: 'stop'; code: number };
743
+
744
+ const continueAssimilation = <T>(value: T): AssimilationPhaseOutcome<T> => ({
745
+ kind: 'continue',
746
+ value,
747
+ });
748
+
749
+ export interface RepositoryResolutionOutcome {
750
+ mode: 'assimilate' | 'cube-init';
751
+ repositoryContext: GitRepositoryContext;
752
+ }
753
+
754
+ export type RepositoryResolutionDeps = Pick<
755
+ AssimilateDeps,
756
+ 'cwd' | 'resolveRepositoryContext' | 'stderr'
757
+ >;
758
+
759
+ export async function resolveAssimilationRepository(
720
760
  args: AssimilateArgs,
721
- deps: AssimilateDeps,
722
- options: RunAssimilateOptions = {},
723
- ): Promise<number> {
761
+ deps: RepositoryResolutionDeps,
762
+ ): Promise<AssimilationPhaseOutcome<RepositoryResolutionOutcome>> {
724
763
  const mode = args.mode ?? 'assimilate';
725
- // ----- Input validation (before any subprocess work) -----
726
- // A role is a lookup key, not a path component. matchRoleByName() below
727
- // applies the shared roleSlug() normalization, so displayed names such as
728
- // "Builder" and "Code Reviewer" must reach that resolver. Keep the strict
729
- // identifier validator for worktree names, which do become path components.
730
764
  if (args.flags.worktree !== undefined) {
731
- const v = validateName(args.flags.worktree);
732
- if (!v.ok) {
733
- deps.stderr(v.error + '\n');
734
- return 1;
765
+ const validation = validateName(args.flags.worktree);
766
+ if (!validation.ok) {
767
+ deps.stderr(validation.error + '\n');
768
+ return { kind: 'stop', code: 1 };
735
769
  }
736
770
  }
737
771
  if (args.flags.cubeName !== undefined && !validRepositoryCubeName(args.flags.cubeName.trim())) {
738
772
  deps.stderr('Invalid cube name. Use 1-120 letters, digits, spaces, dots, underscores, or hyphens, starting with a letter or digit.\n');
739
- return 1;
773
+ return { kind: 'stop', code: 1 };
740
774
  }
741
775
 
742
776
  let repositoryContext: GitRepositoryContext | null;
@@ -746,13 +780,13 @@ export async function runAssimilate(
746
780
  if (error instanceof Error && error.message === 'BARE_REPOSITORY') {
747
781
  const command = mode === 'cube-init' ? 'borg server cube init' : 'borg assimilate';
748
782
  deps.stderr(`${command} requires a non-bare repository worktree. Clone or check out the repository, then retry.\n`);
749
- return 1;
783
+ return { kind: 'stop', code: 1 };
750
784
  }
751
785
  deps.stderr(
752
786
  `Could not inspect this Git repository: ${repositoryDiscoveryFailureMessage(error)}\n` +
753
787
  'Nothing was changed.\n',
754
788
  );
755
- return 1;
789
+ return { kind: 'stop', code: 1 };
756
790
  }
757
791
  if (!repositoryContext) {
758
792
  deps.stderr(
@@ -760,317 +794,973 @@ export async function runAssimilate(
760
794
  'Nothing was changed.\n' +
761
795
  'Run this command inside a Git repository.\n',
762
796
  );
763
- return 1;
797
+ return { kind: 'stop', code: 1 };
764
798
  }
765
799
 
766
- const hostlessEnrollment = args.flags.enroll === true &&
767
- args.flags.server === undefined && deps.defaultAuthority === undefined;
768
- const artifactOnlyEnrollment = hostlessEnrollment && deps.isTTY();
769
- let preResumeAttempted = false;
770
- let preResumedEnrollment: {
771
- token: string;
772
- trustIdentity: string;
773
- serverCapabilities?: readonly string[];
774
- } | null = null;
800
+ return continueAssimilation({ mode, repositoryContext });
801
+ }
775
802
 
776
- let prefetchedInvitation: string | undefined;
777
- let prefetchedArtifact: InvitationArtifact | undefined;
803
+ export type FinalizationDeps = Pick<
804
+ AssimilateDeps,
805
+ 'cwd' | 'finalizeServerSeat' | 'findProjectRoot' | 'stderr'
806
+ >;
807
+
808
+ export interface FinalizationInput {
809
+ activeCube: AssimilationActiveCube;
810
+ apiUrl: string;
811
+ repositoryContext: GitRepositoryContext;
812
+ result: AssimilateResult;
813
+ sessionExpected: ExpectedBinding;
814
+ rollbackWorktree: () => void;
815
+ }
778
816
 
779
- if (hostlessEnrollment && !deps.isTTY()) {
780
- deps.stderr(
781
- 'Local enrollment requires an interactive operator terminal. ' +
782
- `Re-run ${localAssimilateCommand(undefined, true, mode)} from the operator’s terminal.\n`,
783
- );
784
- return 1;
817
+ export async function finalizeAssimilationSeat(
818
+ input: FinalizationInput,
819
+ deps: FinalizationDeps,
820
+ ): Promise<AssimilationPhaseOutcome<void>> {
821
+ const { activeCube, apiUrl, repositoryContext, result, sessionExpected, rollbackWorktree } = input;
822
+ if (result.finalize === undefined || deps.finalizeServerSeat === undefined) {
823
+ deps.stderr('Local Borg server session metadata is incomplete; no connection was saved.\n');
824
+ rollbackWorktree();
825
+ return { kind: 'stop', code: 1 };
785
826
  }
786
827
 
787
- // An explicit --host plus a new invitation must be rejected on the pure
788
- // input path. Decode and compare the operator-presented artifact before any
789
- // pending-enrollment lookup: the lookup enumerates the credential backend,
790
- // so it must not precede this contradiction check. A matching artifact may
791
- // then take the published pending-resume path until client#267 lands.
792
- if (args.flags.enroll && args.flags.server !== undefined && deps.isTTY()) {
793
- let preResumeOrigin: string;
794
- try {
795
- preResumeOrigin = normalizeServerEndpoint(args.flags.server);
796
- } catch (error) {
797
- deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
798
- return 1;
799
- }
800
-
801
- prefetchedInvitation = await deps.promptSecret('Enrollment invitation (single-use; hidden input):');
802
- if (!prefetchedInvitation) {
803
- deps.stderr('No enrollment invitation was entered. Ask the server operator for one, then retry.\n');
804
- return 1;
805
- }
806
- try {
807
- prefetchedArtifact = decodeAndVerifyInvitationArtifact(prefetchedInvitation);
808
- if (prefetchedArtifact.endpoint !== preResumeOrigin) {
809
- throw new InvitationArtifactEndpointMismatchError(
810
- preResumeOrigin,
811
- prefetchedArtifact.endpoint,
812
- );
813
- }
814
- } catch (error) {
815
- deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
816
- prefetchedInvitation = undefined;
817
- prefetchedArtifact = undefined;
818
- return 1;
819
- }
828
+ let outcome: FinalizeServerSeatOutcome;
829
+ try {
830
+ outcome = await deps.finalizeServerSeat({
831
+ active: activeCube,
832
+ commonDir: repositoryContext.commonDir,
833
+ ...(repositoryContext.publicRepository
834
+ ? { repositoryOrigin: repositoryContext.publicRepository.value }
835
+ : {}),
836
+ expected: sessionExpected,
837
+ activate: result.finalize.activate,
838
+ scrubPending: result.finalize.scrubPending,
839
+ });
840
+ } catch (error) {
841
+ const message = error instanceof Error ? error.message : String(error);
842
+ deps.stderr(`finalizeServerSeat failed: ${message}\n`);
843
+ rollbackWorktree();
844
+ return { kind: 'stop', code: 1 };
845
+ }
820
846
 
821
- let pendingForHost = false;
822
- if (deps.peekPendingServerEnrollment !== undefined) {
823
- let pending: { origin: string; invitation: string } | null = null;
824
- try {
825
- pending = await deps.peekPendingServerEnrollment();
826
- } catch {
827
- pending = null;
828
- }
829
- if (pending?.origin === preResumeOrigin) {
830
- try {
831
- const pendingArtifact = decodeAndVerifyInvitationArtifact(pending.invitation);
832
- if (pendingArtifact.endpoint !== preResumeOrigin) {
833
- throw new InvitationArtifactEndpointMismatchError(
834
- preResumeOrigin,
835
- pendingArtifact.endpoint,
836
- );
837
- }
838
- pendingForHost = true;
839
- } catch (error) {
840
- deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
841
- return 1;
842
- }
843
- }
844
- }
847
+ if (outcome.committed) return continueAssimilation(undefined);
845
848
 
846
- if (pendingForHost) {
849
+ if (outcome.reason === 'activation-failed') {
850
+ let bindOutcome: BindPendingSeatOutcome | 'threw' | 'unavailable' = 'unavailable';
851
+ if (result.finalize.bindPending) {
847
852
  try {
848
- preResumeAttempted = true;
849
- preResumedEnrollment = await deps.resumeServerEnrollment(preResumeOrigin, () => {
850
- deps.stderr(
851
- `Resuming the pending enrollment for \`${preResumeOrigin}\`; ` +
852
- 'do not enter another invitation unless the server certificate was reissued; ' +
853
- 'if it was, request a current invitation and rerun this command.\n',
854
- );
855
- });
853
+ bindOutcome = (await result.finalize.bindPending({
854
+ worktree: deps.findProjectRoot(deps.cwd()),
855
+ name: activeCube.name,
856
+ droneLabel: activeCube.droneLabel,
857
+ ...(activeCube.roleName !== undefined ? { roleName: activeCube.roleName } : {}),
858
+ ...(activeCube.roleClass !== undefined ? { roleClass: activeCube.roleClass } : {}),
859
+ ...(activeCube.isHumanSeat !== undefined ? { isHumanSeat: activeCube.isHumanSeat } : {}),
860
+ })) as BindPendingSeatOutcome;
856
861
  } catch {
857
- preResumeAttempted = false;
858
- }
859
- }
860
- }
861
- if (artifactOnlyEnrollment ||
862
- preResumeAttempted && preResumedEnrollment === null) {
863
- if (artifactOnlyEnrollment && deps.resumePendingServerEnrollment) {
864
- preResumedEnrollment = await deps.resumePendingServerEnrollment(() => {
865
- deps.stderr('Resuming the pending enrollment; no new invitation is required.\n');
866
- });
867
- }
868
- if (artifactOnlyEnrollment && preResumedEnrollment) {
869
- // The exact pending tuple was already redeemed or is being resumed.
870
- } else {
871
- prefetchedInvitation = await deps.promptSecret('Enrollment invitation (single-use; hidden input):');
872
- if (!prefetchedInvitation) {
873
- deps.stderr('No enrollment invitation was entered. Ask the server operator for one, then retry.\n');
874
- return 1;
875
- }
876
- try {
877
- prefetchedArtifact = decodeAndVerifyInvitationArtifact(prefetchedInvitation);
878
- } catch (error) {
879
- deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
880
- prefetchedInvitation = undefined;
881
- return 1;
862
+ bindOutcome = 'threw';
882
863
  }
883
864
  }
884
- }
885
- if (!artifactOnlyEnrollment && args.flags.server === undefined && deps.defaultAuthority === undefined) {
886
- const connectCommand = mode === 'cube-init'
887
- ? 'borg server cube init --host <host>'
888
- : 'borg assimilate --host <host>';
889
- const serverInstall = await deps.ensureLocalServerInstalled(connectCommand);
890
- if (serverInstall !== 'present') {
891
- // A newly installed server still needs its explicit setup/start journey.
892
- // Decline, non-interactive, and failure paths have already printed exact
893
- // recovery commands. None may continue into private-state mutation.
894
- return serverInstall === 'installed' ? 0 : 1;
895
- }
896
- }
897
-
898
- try {
899
- await deps.preparePrivateRoot();
900
- } catch {
901
- deps.stderr(`${PRIVATE_STATE_UNAVAILABLE_COPY}\n`);
902
- return 1;
903
- }
904
-
905
- // Read local seat state before authority discovery, which may probe the local
906
- // server. A retired replacement collision must not send either saved bearer or
907
- // perform any other network request.
908
- let existing: CanonicalActiveCube | null = null;
909
- let hasPersistedIdentity = false;
910
- let localSeatReadError: unknown;
911
- try {
912
- existing = await deps.getActiveCube();
913
- hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
914
- } catch (error) {
915
- if (error instanceof LegacySessionCredentialCollisionError) {
916
- return reportServerFailure(deps, error.origin, error, false, mode);
865
+ if (bindOutcome === 'bound') {
866
+ deps.stderr(
867
+ `This worktree's secure session on ${apiUrl} did not finish activating, but ` +
868
+ 'its resumable connection state was PRESERVED here. This worktree was NOT removed. From ' +
869
+ `here, re-run ${localAssimilateCommand(apiUrl)} to converge (the identical connection ` +
870
+ `is reused no duplicate is minted), or run ${resetLocalSeatCommand(apiUrl)} to ` +
871
+ 'clear it.\n',
872
+ );
873
+ return { kind: 'stop', code: 1 };
917
874
  }
918
- localSeatReadError = error;
875
+ const bindFailure =
876
+ bindOutcome === 'missing'
877
+ ? 'the exact pending connection record went missing locally before it could be bound'
878
+ : bindOutcome === 'replaced'
879
+ ? 'the exact pending connection record was replaced locally before it could be bound; the replacement was left untouched'
880
+ : bindOutcome === 'threw'
881
+ ? 'the private store could not be read or written while preserving the pending connection'
882
+ : 'this client did not receive a pending-connection preservation handle';
883
+ deps.stderr(
884
+ `This worktree's secure session on ${apiUrl} did not finish activating: ` +
885
+ `${bindFailure}. The spawned worktree will be removed. No client-only command can ` +
886
+ 'prove reuse or safely clear the possibly accepted server-side drone; ask the server ' +
887
+ 'operator to inspect that drone before retrying.\n',
888
+ );
889
+ rollbackWorktree();
890
+ return { kind: 'stop', code: 1 };
919
891
  }
920
892
 
921
- // ----- Step 1: Select and authenticate the local server -----
922
- const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode);
923
- if (!selectedAuthority) return 1;
924
- let authority: AssimilationAuthority = selectedAuthority;
925
- if (localSeatReadError !== undefined) {
926
- return reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode);
927
- }
893
+ deps.stderr(
894
+ `This worktree's saved connection to ${apiUrl} changed during attach ` +
895
+ '(a concurrent reset or enroll); no drone was created and nothing was overwritten. ' +
896
+ `Re-run ${localAssimilateCommand(apiUrl)} to attach against the current state.\n`,
897
+ );
898
+ rollbackWorktree();
899
+ return { kind: 'stop', code: 1 };
900
+ }
928
901
 
929
- const projectRoot = repositoryContext.root;
930
- const wantSibling =
931
- args.flags.worktree !== undefined ||
932
- (!args.flags.here && !(existing === null && hasPersistedIdentity));
933
- let verifiedHead = '';
902
+ export type LaunchDeps = Pick<
903
+ AssimilateDeps,
904
+ | 'exec'
905
+ | 'findLoadedCodexThread'
906
+ | 'getInboxPath'
907
+ | 'isTTY'
908
+ | 'prepareCodexRemoteLaunch'
909
+ | 'probeMcpReady'
910
+ | 'resolveCliApprovals'
911
+ | 'setCodexWakeTarget'
912
+ | 'setTerminalTitle'
913
+ | 'stderr'
914
+ | 'stdout'
915
+ >;
916
+
917
+ export interface LaunchInput {
918
+ flags: AssimilateFlags;
919
+ result: AssimilateResult;
920
+ cubeDetail: CubeDetail;
921
+ assignedRole: Role;
922
+ apiUrl: string;
923
+ cli: BorgCli;
924
+ effectiveModel: string | null;
925
+ agentCwd: string;
926
+ seatWorktree: string;
927
+ scratchRoot: string;
928
+ launchAccessPaths: LaunchAccessPaths;
929
+ monitorStateRoot: string;
930
+ spawnedWorktreePath: string | null;
931
+ originalCwd: string;
932
+ }
934
933
 
935
- if (mode !== 'cube-init' && args.flags.here && existing === null && !hasPersistedIdentity) {
934
+ export async function launchAssimilatedAgent(
935
+ input: LaunchInput,
936
+ deps: LaunchDeps,
937
+ ): Promise<number> {
938
+ const {
939
+ flags,
940
+ result,
941
+ cubeDetail,
942
+ assignedRole,
943
+ apiUrl,
944
+ cli,
945
+ effectiveModel,
946
+ agentCwd,
947
+ seatWorktree,
948
+ scratchRoot,
949
+ launchAccessPaths,
950
+ monitorStateRoot,
951
+ spawnedWorktreePath,
952
+ originalCwd,
953
+ } = input;
954
+ deps.setTerminalTitle(result.drone_label, cubeDetail.name);
955
+ const useColor = deps.isTTY() && !process.env.NO_COLOR && !process.env.CI;
956
+ deps.stdout(renderAssimilationWelcome(
957
+ result.drone_label,
958
+ assignedRole.name,
959
+ cubeDetail.name,
960
+ useColor,
961
+ apiUrl,
962
+ ));
963
+
964
+ if (!await deps.probeMcpReady()) {
936
965
  deps.stderr(
937
- '`borg assimilate --here` resumes this worktree\'s saved drone, but no saved drone was found.\n' +
938
- 'Run `borg assimilate` to create a new drone in a managed worktree.\n',
966
+ `warning: borg-mcp readiness probe did not complete within the timeout; ` +
967
+ `launching ${cli} anyway the kickoff prompt's ToolSearch fallback ` +
968
+ `will recover if the MCP server takes longer to start.\n`,
939
969
  );
940
- return 1;
941
970
  }
971
+ const inboxPath = deps.getInboxPath(result.cube_id, result.drone_id);
972
+ const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
973
+ const monitorClause = buildKickoffWakePathClause(
974
+ cli,
975
+ cli === 'claude' ? inboxPath : null,
976
+ cli === 'claude' ? monitorStateRoot : null,
977
+ );
978
+ let codexWakePathClause: string | undefined;
979
+ let remoteArgs: string[] = [];
980
+ let launchArgs: string[];
981
+ let codexSocketPath: string | null = null;
982
+ let codexServerCleanup: (() => void) | null = null;
983
+ const launchApproval = deps.resolveCliApprovals
984
+ ? await deps.resolveCliApprovals(cli, agentCwd, { skipOverride: flags.noBorgApprovalOverride })
985
+ : { codexArgs: [] };
986
+ if (launchApproval.warning) deps.stderr(`warning: ${launchApproval.warning}\n`);
942
987
 
943
- if (mode !== 'cube-init' && wantSibling) {
944
- const headProbe = deps.runSync('git', ['rev-parse', '--verify', 'HEAD'], projectRoot);
945
- if (headProbe.status !== 0) {
946
- deps.stderr(
947
- 'sibling worktree spawn requires HEAD pointing at a commit.\n' +
948
- 'Create an initial commit (for example: `git commit --allow-empty -m "Initial commit"`), then rerun `borg assimilate`.\n',
949
- );
950
- return 1;
951
- }
952
- verifiedHead = headProbe.stdout.trim();
988
+ const modelEnv = resolveLaunchEnv(effectiveModel);
989
+ const childEnv: Record<string, string> = {
990
+ ...(withAgentRuntimeEnv(process.env, cli) as Record<string, string>),
991
+ ...modelEnv.set,
992
+ BORG_SESSION: '1',
993
+ [BORG_LAUNCH_CLI_ENV]: cli,
994
+ [BORG_LAUNCH_WORKTREE_ENV]: seatWorktree,
995
+ [BORG_LAUNCH_SCRATCH_ENV]: scratchRoot,
996
+ };
997
+ if (cli === 'opencode' && launchApproval.openCodePermission) {
998
+ childEnv.OPENCODE_PERMISSION = launchApproval.openCodePermission;
953
999
  }
1000
+ for (const key of modelEnv.unset) delete childEnv[key];
954
1001
 
955
- let auth: { token: string; apiUrl: string; serverTrustIdentity: string; serverCapabilities: readonly string[] };
956
- {
957
- try {
958
- let serverAuth: {
959
- token: string;
960
- trustIdentity: string;
961
- serverCapabilities?: readonly string[];
962
- };
963
- if (args.flags.enroll) {
964
- if (!deps.isTTY()) {
965
- deps.stderr(
966
- 'Local enrollment requires an interactive operator terminal. ' +
967
- `Re-run ${localAssimilateCommand(authority.apiUrl, true, mode)} from the operator’s terminal.\n`,
968
- );
969
- return 1;
970
- }
971
- let resumed = preResumedEnrollment;
972
- if (!resumed && prefetchedArtifact === undefined && !preResumeAttempted && !artifactOnlyEnrollment) {
973
- resumed = await deps.resumeServerEnrollment(authority.apiUrl, () => {
974
- deps.stderr(
975
- `Resuming the pending enrollment for \`${authority.apiUrl}\`; ` +
976
- 'do not enter another invitation unless the server certificate was reissued; ' +
977
- 'if it was, request a current invitation and rerun this command.\n',
978
- );
979
- });
980
- }
981
- if (resumed) {
982
- if ('apiUrl' in resumed && typeof resumed.apiUrl === 'string') {
983
- authority = { kind: 'server', apiUrl: resumed.apiUrl };
984
- }
985
- serverAuth = resumed;
986
- } else {
987
- let invitation = prefetchedInvitation ?? await deps.promptSecret(
988
- artifactOnlyEnrollment
989
- ? 'Enrollment invitation (single-use; hidden input):'
990
- : `Enrollment invitation for \`${authority.apiUrl}\` (single-use; hidden input):`,
991
- );
992
- if (!invitation) {
993
- deps.stderr(artifactOnlyEnrollment
994
- ? 'No enrollment invitation was entered. Ask the server operator for one, then rerun `borg assimilate --enroll`.\n'
995
- : `No enrollment invitation was entered for ${authority.apiUrl}. ` +
996
- `Ask the server operator for one, then rerun ${localAssimilateCommand(authority.apiUrl, true, mode)}.\n`);
997
- return 1;
998
- }
999
- try {
1000
- const artifact = prefetchedArtifact ?? decodeAndVerifyInvitationArtifact(invitation);
1001
- if (args.flags.server !== undefined && authority.apiUrl !== artifact.endpoint) {
1002
- throw new InvitationArtifactEndpointMismatchError(authority.apiUrl, artifact.endpoint);
1003
- }
1004
- authority = { kind: 'server', apiUrl: artifact.endpoint };
1005
- serverAuth = await deps.connectServer(authority.apiUrl, {
1006
- invitation,
1007
- artifact,
1008
- confirmReplacement: async () => strictAffirmative(await deps.prompt(
1009
- `A local enrollment for ${authority.apiUrl} already exists. Replacing it will orphan ` +
1010
- 'the first enrolled client. Replace it? [y/N]: ',
1011
- )),
1012
- });
1013
- } finally {
1014
- // Strings cannot be zeroized in JavaScript, but drop this command's
1015
- // reference immediately after the exchange instead of retaining the
1016
- // invitation through the rest of assimilation/agent launch.
1017
- invitation = '';
1018
- }
1019
- }
1020
- if (serverAuth.serverCapabilities?.includes('create_cube')) {
1021
- deps.stderr(
1022
- `Owner client enrolled with \`${authority.apiUrl}\`. ` +
1023
- 'Creating or joining this repository’s cube next.\n',
1024
- );
1025
- } else {
1026
- deps.stderr(
1027
- `Ordinary client enrolled with \`${authority.apiUrl}\`. ` +
1028
- 'Checking for an accessible repository cube next.\n',
1029
- );
1030
- }
1031
- } else {
1032
- serverAuth = await deps.connectServer(authority.apiUrl);
1033
- }
1034
- auth = {
1035
- token: serverAuth.token,
1036
- apiUrl: authority.apiUrl,
1037
- serverTrustIdentity: serverAuth.trustIdentity,
1038
- serverCapabilities: serverAuth.serverCapabilities ?? [],
1039
- };
1040
- if (args.flags.enroll) {
1041
- deps.stderr(
1042
- `This machine (${deps.getHostname()}) is enrolled with Borg server \`${authority.apiUrl}\`.\n`,
1043
- );
1044
- }
1045
- } catch (error) {
1046
- return reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode);
1002
+ if (cli === 'codex') {
1003
+ const remote = await deps.prepareCodexRemoteLaunch();
1004
+ if (remote.warning) {
1005
+ deps.stderr(`warning: ${remote.warning}\n`);
1006
+ codexWakePathClause =
1007
+ '⚠ Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg_regen manually whenever you return, and expect only fallback wakeups until relaunch.';
1008
+ } else {
1009
+ codexWakePathClause = 'Codex wake-path capability check passed: remote-control socket established for this session.';
1047
1010
  }
1011
+ remoteArgs = remote.args;
1012
+ if (Object.keys(remote.env).length > 0) Object.assign(childEnv, remote.env);
1013
+ codexSocketPath = socketPathFromRemoteArgs(remote.args);
1014
+ codexServerCleanup = remote.server?.cleanup ?? null;
1048
1015
  }
1049
-
1050
- // ----- Sprint 19 (gh#184): Reorder for strict-rollback semantics. -----
1051
- // The previous flow created a sibling worktree (FS state) BEFORE
1052
- // role resolution + API assimilate. Any early-return between
1053
- // worktree-spawn and API success orphaned the worktree (gh#184
1054
- // canonical case: unknown role arg). The new flow defers all FS
1055
- // state until AFTER the API assimilate succeeds — early-return at
1056
- // role resolution / listCubes / createCube / template-prompt /
1057
- // template-invalid-choice is now structurally clean (no orphan
1058
- // class possible). Worktree rollback narrows to local finalization failures
1059
- // after worktree creation.
1060
-
1061
- // Sprint 18: capture pre-chdir cwd for the post-exit shell-cd hint
1062
- // (no chdir has happened yet; this is a stable starting point).
1063
- const originalCwd = deps.cwd();
1064
-
1065
- let initialized;
1066
- try {
1067
- initialized = await initializeRepositoryCube({
1068
- mode,
1069
- context: repositoryContext,
1070
- serverOrigin: auth.apiUrl,
1071
- flags: args.flags,
1072
- canCreate: auth.serverCapabilities.includes('create_cube'),
1073
- }, {
1016
+ const kickoff = buildAgentKickoffPrompt({
1017
+ cli,
1018
+ codexWakeNonce,
1019
+ monitorClause,
1020
+ codexWakePathClause,
1021
+ });
1022
+ let openCodeKickoff: ReturnType<typeof createOpenCodeLaunchKickoff> | null = null;
1023
+ let dronePort: number | undefined;
1024
+ launchArgs = [kickoff];
1025
+ if (cli === 'codex') {
1026
+ launchArgs = [
1027
+ ...codexLaunchDirectoryArgs(launchAccessPaths),
1028
+ ...launchApproval.codexArgs,
1029
+ ...codexBorgSessionConfigArgs(),
1030
+ ...codexAgentKindConfigArgs(),
1031
+ ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
1032
+ ...codexStateRootConfigArgs(),
1033
+ ...remoteArgs,
1034
+ ...withCodexCwdArg(launchArgs, agentCwd),
1035
+ ];
1036
+ } else if (cli === 'opencode') {
1037
+ dronePort = await allocateOpenCodePort();
1038
+ childEnv.BORG_OPENCODE_PORT = String(dronePort);
1039
+ installBorgPlugin();
1040
+ openCodeKickoff = createOpenCodeLaunchKickoff(kickoff);
1041
+ childEnv[OPENCODE_SERVER_USERNAME_ENV] = OPENCODE_SERVER_USERNAME;
1042
+ childEnv[OPENCODE_SERVER_PASSWORD_ENV] = openCodeKickoff.apiPassword;
1043
+ childEnv[BORG_OPENCODE_LAUNCH_CORRELATION_ENV] = openCodeKickoff.correlationIdentity;
1044
+ launchArgs = buildOpenCodeLaunchArgs(agentCwd, dronePort, openCodeKickoff.prompt);
1045
+ }
1046
+
1047
+ const exitPromise = deps.exec(cli, launchArgs, agentCwd, childEnv);
1048
+ if (cli === 'codex' && codexSocketPath && codexWakeNonce) {
1049
+ void recordCodexWakeTarget({
1050
+ deps,
1051
+ cubeId: result.cube_id,
1052
+ droneId: result.drone_id,
1053
+ socketPath: codexSocketPath,
1054
+ cwd: agentCwd,
1055
+ previewNeedle: codexWakeNonce,
1056
+ launchedAtSeconds: Math.floor(Date.now() / 1000),
1057
+ });
1058
+ }
1059
+ if (cli === 'opencode' && openCodeKickoff) {
1060
+ const launchKickoff = openCodeKickoff;
1061
+ connectOpenCodeDrone({
1062
+ serverUrl: `http://127.0.0.1:${dronePort}`,
1063
+ apiPassword: launchKickoff.apiPassword,
1064
+ directory: agentCwd,
1065
+ droneLabel: result.drone_label,
1066
+ cubeName: cubeDetail.name,
1067
+ launchIdentity: launchKickoff.correlationIdentity,
1068
+ }).then(() => injectInitialKickoff(launchKickoff)).catch(() => {});
1069
+ }
1070
+ const exitCode = await exitPromise;
1071
+ if (codexServerCleanup) {
1072
+ try {
1073
+ codexServerCleanup();
1074
+ } catch {
1075
+ // Best-effort cleanup after a normal Codex exit.
1076
+ }
1077
+ }
1078
+ if (spawnedWorktreePath && originalCwd !== spawnedWorktreePath) {
1079
+ deps.stderr(
1080
+ `\nAgent exited. You were working in ${spawnedWorktreePath}; your shell is back in ${originalCwd}.\n` +
1081
+ 'To return:\n' +
1082
+ ` cd ${shellEscape(spawnedWorktreePath)}\n`,
1083
+ );
1084
+ }
1085
+ return exitCode;
1086
+ }
1087
+
1088
+ export type CubeRoleResolutionDeps = Pick<
1089
+ AssimilateDeps,
1090
+ 'resolveCli' | 'stderr'
1091
+ >;
1092
+
1093
+ export interface CubeRoleResolutionInput {
1094
+ requestedRole: string | undefined;
1095
+ flags: AssimilateFlags;
1096
+ cubeDetail: CubeDetail;
1097
+ isFirstDrone: boolean;
1098
+ savedLocalRole: Role | undefined;
1099
+ apiUrl: string;
1100
+ }
1101
+
1102
+ export interface CubeRoleResolutionOutcome {
1103
+ resolvedRole: Role;
1104
+ effectiveModel: string | null;
1105
+ cli: BorgCli;
1106
+ }
1107
+
1108
+ export async function resolveAssimilationCubeRole(
1109
+ input: CubeRoleResolutionInput,
1110
+ deps: CubeRoleResolutionDeps,
1111
+ ): Promise<AssimilationPhaseOutcome<CubeRoleResolutionOutcome>> {
1112
+ const { requestedRole, flags, cubeDetail, isFirstDrone, savedLocalRole, apiUrl } = input;
1113
+ let resolvedRole: Role | undefined;
1114
+ if (savedLocalRole) {
1115
+ resolvedRole = savedLocalRole;
1116
+ } else if (requestedRole !== undefined) {
1117
+ resolvedRole = matchRoleByName(cubeDetail.roles, requestedRole);
1118
+ if (!resolvedRole) {
1119
+ const available = cubeDetail.roles.map((role) => role.name).join(', ');
1120
+ const suggestion = suggestRoleName(requestedRole, cubeDetail.roles.map((role) => role.name));
1121
+ const suggestionLine = suggestion ? ` Did you mean "${suggestion}"?` : '';
1122
+ deps.stderr(
1123
+ `No role matching "${requestedRole}" in cube "${cubeDetail.name}" on ${apiUrl}. ` +
1124
+ `Available: ${available}.${suggestionLine}\n` +
1125
+ `Rerun ${localAssimilateRoleCommand(apiUrl)} with one of the available roles.\n`,
1126
+ );
1127
+ return { kind: 'stop', code: 1 };
1128
+ }
1129
+ } else {
1130
+ const occupiedRoleIds = occupiedRoleIdsForAutoRole(cubeDetail.drones ?? []);
1131
+ resolvedRole = pickDefaultRole(cubeDetail.roles, { isFirstDrone, occupiedRoleIds });
1132
+ if (!resolvedRole) {
1133
+ deps.stderr(
1134
+ `Cube "${cubeDetail.name}" on ${apiUrl} has no default or human-seat role. ` +
1135
+ `Ask the server operator to configure a role, then rerun ` +
1136
+ `${localAssimilateRoleCommand(apiUrl)}.\n`,
1137
+ );
1138
+ return { kind: 'stop', code: 1 };
1139
+ }
1140
+ }
1141
+
1142
+ const effectiveModel: string | null = flags.model ?? null;
1143
+ const cli = await deps.resolveCli(flags.cli);
1144
+ try {
1145
+ ensureCliMcpConfigured(cli);
1146
+ } catch (error) {
1147
+ const message = error instanceof Error ? error.message : String(error);
1148
+ deps.stderr(
1149
+ `${cli} MCP configuration failed for ${apiUrl}: ${safeStderr(message)}. ` +
1150
+ `Fix the ${cli} MCP configuration, then rerun ${localAssimilateCliCommand(apiUrl, cli)}.\n`,
1151
+ );
1152
+ return { kind: 'stop', code: 1 };
1153
+ }
1154
+ return continueAssimilation({ resolvedRole, effectiveModel, cli });
1155
+ }
1156
+
1157
+ export type SeatPreparationDeps = Pick<
1158
+ AssimilateDeps,
1159
+ 'assimilate' | 'getHostname' | 'stderr'
1160
+ >;
1161
+
1162
+ export interface SeatPreparationInput {
1163
+ apiUrl: string;
1164
+ token: string;
1165
+ serverTrustIdentity: string;
1166
+ cubeDetail: CubeDetail;
1167
+ resolvedRole: Role;
1168
+ cli: BorgCli;
1169
+ effectiveModel: string | null;
1170
+ projectRoot: string;
1171
+ existing: CanonicalActiveCube | null;
1172
+ reattachPriorId: string | undefined;
1173
+ remintInvalidPrior: boolean;
1174
+ resumeCredentialRef: string | undefined;
1175
+ resumeDroneId: string | undefined;
1176
+ resumeState: 'pending' | 'active' | undefined;
1177
+ sessionOperation: ServerSessionOperation;
1178
+ }
1179
+
1180
+ export interface SeatPreparationOutcome {
1181
+ result: AssimilateResult;
1182
+ assignedRole: Role;
1183
+ sessionExpected: ExpectedBinding;
1184
+ }
1185
+
1186
+ export async function prepareAssimilationSeat(
1187
+ input: SeatPreparationInput,
1188
+ deps: SeatPreparationDeps,
1189
+ ): Promise<AssimilationPhaseOutcome<SeatPreparationOutcome>> {
1190
+ const {
1191
+ apiUrl,
1192
+ token,
1193
+ serverTrustIdentity,
1194
+ cubeDetail,
1195
+ resolvedRole,
1196
+ cli,
1197
+ effectiveModel,
1198
+ projectRoot,
1199
+ existing,
1200
+ reattachPriorId,
1201
+ remintInvalidPrior,
1202
+ resumeCredentialRef,
1203
+ resumeDroneId,
1204
+ resumeState,
1205
+ sessionOperation,
1206
+ } = input;
1207
+ let sessionExpected: ExpectedBinding;
1208
+ if (resumeCredentialRef && resumeState === 'pending') {
1209
+ sessionExpected = { kind: 'absent' };
1210
+ } else if (resumeCredentialRef) {
1211
+ sessionExpected = {
1212
+ kind: 'exact',
1213
+ credentialRef: resumeCredentialRef,
1214
+ ...(resumeDroneId ? { droneId: resumeDroneId } : {}),
1215
+ };
1216
+ } else if (remintInvalidPrior && existing?.localSessionCredentialRef) {
1217
+ sessionExpected = {
1218
+ kind: 'exact',
1219
+ credentialRef: existing.localSessionCredentialRef,
1220
+ ...(existing.droneId ? { droneId: existing.droneId } : {}),
1221
+ };
1222
+ } else if (reattachPriorId != null && existing?.localSessionCredentialRef && existing.sessionToken) {
1223
+ sessionExpected = {
1224
+ kind: 'exact',
1225
+ credentialRef: existing.localSessionCredentialRef,
1226
+ ...(existing.droneId ? { droneId: existing.droneId } : {}),
1227
+ sessionDigest: createHash('sha256').update(existing.sessionToken).digest('hex'),
1228
+ };
1229
+ } else {
1230
+ sessionExpected = { kind: 'absent' };
1231
+ }
1232
+
1233
+ deps.stderr(`Joining cube '${cubeDetail.name}' as ${resolvedRole.name}…\n`);
1234
+ let result: AssimilateResult;
1235
+ try {
1236
+ result = await deps.assimilate(
1237
+ apiUrl,
1238
+ token,
1239
+ {
1240
+ cube_id: cubeDetail.id,
1241
+ role_id: resolvedRole.id,
1242
+ hostname: deps.getHostname(),
1243
+ agent_kind: cli,
1244
+ model: effectiveModel,
1245
+ working_repo: resolveWorkingRepo(projectRoot),
1246
+ ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
1247
+ ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
1248
+ session_operation: sessionOperation,
1249
+ session_expected: sessionExpected,
1250
+ revalidate_at_prepare: true,
1251
+ },
1252
+ serverTrustIdentity,
1253
+ );
1254
+ } catch (error) {
1255
+ if (error instanceof DroneEvictedError && reattachPriorId != null) {
1256
+ deps.stderr(
1257
+ `This worktree's drone on ${apiUrl} was evicted. ` +
1258
+ `Remove this worktree, or from a fresh worktree run ${localAssimilateCommand(apiUrl)}.\n`,
1259
+ );
1260
+ return { kind: 'stop', code: 1 };
1261
+ }
1262
+ if (error instanceof BorgServerError && reattachPriorId != null) {
1263
+ if (error.code === 'SESSION_REVOKED') {
1264
+ return { kind: 'stop', code: diagnoseSessionTermination(deps, apiUrl, 'revoked') };
1265
+ }
1266
+ if (error.code === 'SESSION_REJECTED') {
1267
+ return { kind: 'stop', code: diagnoseSessionTermination(deps, apiUrl, 'superseded') };
1268
+ }
1269
+ }
1270
+ return { kind: 'stop', code: reportServerFailure(deps, apiUrl, error) };
1271
+ }
1272
+
1273
+ if (result.prepareAborted) {
1274
+ deps.stderr(
1275
+ `This worktree's saved connection to ${apiUrl} changed before the attach ` +
1276
+ '(a concurrent reset or enroll); no credential was created or sent and nothing was ' +
1277
+ `changed. Re-run ${localAssimilateCommand(apiUrl)} to attach against the current state.\n`,
1278
+ );
1279
+ return { kind: 'stop', code: 1 };
1280
+ }
1281
+ if (result.local_session === undefined) {
1282
+ return {
1283
+ kind: 'stop',
1284
+ code: reportServerFailure(deps, apiUrl, new Error('Borg server did not return compatible secure session metadata')),
1285
+ };
1286
+ }
1287
+ const assignedRole = cubeDetail.roles.find((role) => role.id === result.role_id) ?? resolvedRole;
1288
+ if (result.result === 'reused') {
1289
+ deps.stderr(`re-attached as ${result.drone_label} (same session, no new drone minted)\n`);
1290
+ } else if (assignedRole.id !== resolvedRole.id) {
1291
+ deps.stderr(
1292
+ `The requested role "${resolvedRole.name}" was unavailable; ` +
1293
+ `attached under the "${assignedRole.name}" role instead.\n`,
1294
+ );
1295
+ }
1296
+ return continueAssimilation({ result, assignedRole, sessionExpected });
1297
+ }
1298
+
1299
+ export type WorktreePreparationDeps = Pick<
1300
+ AssimilateDeps,
1301
+ 'chdir' | 'cwd' | 'homedir' | 'mkdirp' | 'pathExists' | 'runSync' | 'stderr'
1302
+ >;
1303
+
1304
+ export interface WorktreePreparationInput {
1305
+ flags: AssimilateFlags;
1306
+ repositoryContext: GitRepositoryContext;
1307
+ projectRoot: string;
1308
+ wantSibling: boolean;
1309
+ verifiedHead: string;
1310
+ assignedRole: Role;
1311
+ existing: CanonicalActiveCube | null;
1312
+ }
1313
+
1314
+ export interface WorktreePreparationOutcome {
1315
+ spawnedWorktreePath: string | null;
1316
+ }
1317
+
1318
+ export async function prepareAssimilationWorktree(
1319
+ input: WorktreePreparationInput,
1320
+ deps: WorktreePreparationDeps,
1321
+ ): Promise<AssimilationPhaseOutcome<WorktreePreparationOutcome>> {
1322
+ const { flags, repositoryContext, projectRoot, wantSibling, verifiedHead, assignedRole, existing } = input;
1323
+ let spawnedWorktreePath: string | null = null;
1324
+ if (!wantSibling) return continueAssimilation({ spawnedWorktreePath });
1325
+
1326
+ const originProbe = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
1327
+ let startRef = 'HEAD';
1328
+ if (originProbe.status === 0 && originProbe.stdout.trim().length > 0) {
1329
+ deps.runSync('git', ['fetch', 'origin'], projectRoot);
1330
+ const mainProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/main'], projectRoot);
1331
+ if (mainProbe.status === 0) {
1332
+ startRef = 'origin/main';
1333
+ } else if (deps.runSync('git', ['rev-parse', '--verify', 'origin/master'], projectRoot).status === 0) {
1334
+ startRef = 'origin/master';
1335
+ }
1336
+ }
1337
+ if (startRef === 'HEAD') {
1338
+ deps.stderr(`note: no usable origin; new worktree will start on local HEAD (${verifiedHead.slice(0, 7)})\n`);
1339
+ } else {
1340
+ const remoteHead = deps.runSync('git', ['rev-parse', startRef], projectRoot).stdout.trim();
1341
+ if (verifiedHead !== remoteHead) {
1342
+ deps.stderr(
1343
+ `note: local HEAD (${verifiedHead.slice(0, 7)}) differs from ${startRef} (${remoteHead.slice(0, 7)}); ` +
1344
+ `new worktree will start on ${startRef}\n`,
1345
+ );
1346
+ }
1347
+ }
1348
+
1349
+ const repoBase = basename(dirname(repositoryContext.commonDir));
1350
+ const suffix = flags.worktree ?? roleSlug(assignedRole.name);
1351
+ if (suffix.length === 0) {
1352
+ deps.stderr(
1353
+ `cannot derive a worktree name from role "${assignedRole.name}"; ` +
1354
+ 'pass an explicit --worktree <name>\n',
1355
+ );
1356
+ return { kind: 'stop', code: 1 };
1357
+ }
1358
+ const homeDir = deps.homedir();
1359
+ let registeredWorktrees = listRegisteredWorktrees(deps, projectRoot);
1360
+ if (registeredWorktrees === null) {
1361
+ deps.stderr(
1362
+ 'Borg could not enumerate this repository’s existing worktrees, so it did not risk creating a colliding sibling.\n' +
1363
+ 'Run `git worktree list` from this repository and resolve the reported Git error, then rerun `borg assimilate`.\n' +
1364
+ 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n',
1365
+ );
1366
+ return { kind: 'stop', code: 1 };
1367
+ }
1368
+ let candidate = computeWorktreePath(homeDir, repoBase, suffix);
1369
+ let worktreeBranch = perWorktreeBranchName(basename(candidate), repoBase);
1370
+ let suffixNumber = 2;
1371
+ while (
1372
+ deps.pathExists(candidate) ||
1373
+ registeredWorktrees.names.has(basename(candidate)) ||
1374
+ registeredWorktrees.branches.has(worktreeBranch) ||
1375
+ (localBranchExists(deps.runSync, projectRoot, worktreeBranch) &&
1376
+ !isMerged(deps.runSync, projectRoot, worktreeBranch, startRef))
1377
+ ) {
1378
+ candidate = computeWorktreePath(homeDir, repoBase, suffix, suffixNumber);
1379
+ worktreeBranch = perWorktreeBranchName(basename(candidate), repoBase);
1380
+ suffixNumber++;
1381
+ }
1382
+
1383
+ let worktreeResult: ReturnType<AssimilateDeps['runSync']>;
1384
+ let residualBranch: string | null = null;
1385
+ while (true) {
1386
+ deps.mkdirp(dirname(candidate));
1387
+ const branchExisted = localBranchExists(deps.runSync, projectRoot, worktreeBranch);
1388
+ worktreeResult = branchExisted
1389
+ ? deps.runSync('git', ['worktree', 'add', candidate, worktreeBranch], projectRoot)
1390
+ : deps.runSync('git', ['worktree', 'add', '-b', worktreeBranch, candidate, startRef], projectRoot);
1391
+ if (worktreeResult.status === 0) break;
1392
+
1393
+ const refreshed = listRegisteredWorktrees(deps, projectRoot);
1394
+ const branchAppeared = !branchExisted && localBranchExists(deps.runSync, projectRoot, worktreeBranch);
1395
+ const collision =
1396
+ deps.pathExists(candidate) ||
1397
+ refreshed?.names.has(basename(candidate)) === true ||
1398
+ refreshed?.branches.has(worktreeBranch) === true ||
1399
+ (!branchExisted && worktreeAddReportedCollision(worktreeResult.stderr));
1400
+ if (!collision || refreshed === null) {
1401
+ if (branchAppeared && refreshed?.branches.has(worktreeBranch) !== true) residualBranch = worktreeBranch;
1402
+ break;
1403
+ }
1404
+ registeredWorktrees = refreshed;
1405
+ do {
1406
+ candidate = computeWorktreePath(homeDir, repoBase, suffix, suffixNumber);
1407
+ worktreeBranch = perWorktreeBranchName(basename(candidate), repoBase);
1408
+ suffixNumber++;
1409
+ } while (
1410
+ deps.pathExists(candidate) ||
1411
+ registeredWorktrees.names.has(basename(candidate)) ||
1412
+ registeredWorktrees.branches.has(worktreeBranch) ||
1413
+ localBranchExists(deps.runSync, projectRoot, worktreeBranch)
1414
+ );
1415
+ }
1416
+ if (worktreeResult.status !== 0) {
1417
+ deps.stderr(
1418
+ `Borg could not create sibling worktree ${candidate} on branch ${worktreeBranch}. ` +
1419
+ `Git reported: ${safeStderr(worktreeResult.stderr)}\n` +
1420
+ (residualBranch
1421
+ ? `Git left branch ${residualBranch} without a registered worktree; Borg preserved it.\n`
1422
+ : '') +
1423
+ 'Run `git worktree list` and `git status` to inspect repository state, resolve the reported Git error, then rerun `borg assimilate`.\n' +
1424
+ 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n',
1425
+ );
1426
+ return { kind: 'stop', code: 1 };
1427
+ }
1428
+ deps.stderr(
1429
+ `spawned sibling worktree at ${candidate} on branch ${worktreeBranch} (${startRef})` +
1430
+ (existing !== null
1431
+ ? '; the original dir keeps its active drone binding — run `borg reset-local-connection` there if that binding is stale.\n'
1432
+ : '.\n'),
1433
+ );
1434
+ deps.chdir(candidate);
1435
+ deps.stderr(renderWorktreeSteeringNote(candidate, worktreeBranch, projectRoot));
1436
+ spawnedWorktreePath = deps.cwd();
1437
+ return continueAssimilation({ spawnedWorktreePath });
1438
+ }
1439
+
1440
+ export interface AuthorityResolutionInput {
1441
+ args: AssimilateArgs;
1442
+ mode: 'assimilate' | 'cube-init';
1443
+ repositoryContext: GitRepositoryContext;
1444
+ }
1445
+
1446
+ export interface AuthorityResolutionOutcome {
1447
+ authority: AssimilationAuthority;
1448
+ auth: {
1449
+ token: string;
1450
+ apiUrl: string;
1451
+ serverTrustIdentity: string;
1452
+ serverCapabilities: readonly string[];
1453
+ };
1454
+ existing: CanonicalActiveCube | null;
1455
+ hasPersistedIdentity: boolean;
1456
+ projectRoot: string;
1457
+ wantSibling: boolean;
1458
+ verifiedHead: string;
1459
+ }
1460
+
1461
+ export async function resolveAssimilationAuthority(
1462
+ input: AuthorityResolutionInput,
1463
+ deps: AuthorityResolutionDeps,
1464
+ ): Promise<AssimilationPhaseOutcome<AuthorityResolutionOutcome>> {
1465
+ const { args, mode, repositoryContext } = input;
1466
+ const hostlessEnrollment = args.flags.enroll === true &&
1467
+ args.flags.server === undefined && deps.defaultAuthority === undefined;
1468
+ const artifactOnlyEnrollment = hostlessEnrollment && deps.isTTY();
1469
+ let preResumeAttempted = false;
1470
+ let preResumedEnrollment: {
1471
+ token: string;
1472
+ trustIdentity: string;
1473
+ serverCapabilities?: readonly string[];
1474
+ apiUrl?: string;
1475
+ } | null = null;
1476
+ let prefetchedInvitation: string | undefined;
1477
+ let prefetchedArtifact: InvitationArtifact | undefined;
1478
+
1479
+ if (hostlessEnrollment && !deps.isTTY()) {
1480
+ deps.stderr(
1481
+ 'Local enrollment requires an interactive operator terminal. ' +
1482
+ `Re-run ${localAssimilateCommand(undefined, true, mode)} from the operator’s terminal.\n`,
1483
+ );
1484
+ return { kind: 'stop', code: 1 };
1485
+ }
1486
+
1487
+ if (args.flags.enroll && args.flags.server !== undefined && deps.isTTY()) {
1488
+ let preResumeOrigin: string;
1489
+ try {
1490
+ preResumeOrigin = normalizeServerEndpoint(args.flags.server);
1491
+ } catch (error) {
1492
+ deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
1493
+ return { kind: 'stop', code: 1 };
1494
+ }
1495
+ prefetchedInvitation = await deps.promptSecret('Enrollment invitation (single-use; hidden input):');
1496
+ if (!prefetchedInvitation) {
1497
+ deps.stderr('No enrollment invitation was entered. Ask the server operator for one, then retry.\n');
1498
+ return { kind: 'stop', code: 1 };
1499
+ }
1500
+ try {
1501
+ prefetchedArtifact = decodeAndVerifyInvitationArtifact(prefetchedInvitation);
1502
+ if (prefetchedArtifact.endpoint !== preResumeOrigin) {
1503
+ throw new InvitationArtifactEndpointMismatchError(preResumeOrigin, prefetchedArtifact.endpoint);
1504
+ }
1505
+ } catch (error) {
1506
+ deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
1507
+ return { kind: 'stop', code: 1 };
1508
+ }
1509
+
1510
+ let pendingForHost = false;
1511
+ if (deps.peekPendingServerEnrollment) {
1512
+ let pending: { origin: string; invitation: string } | null = null;
1513
+ try {
1514
+ pending = await deps.peekPendingServerEnrollment();
1515
+ } catch {
1516
+ pending = null;
1517
+ }
1518
+ if (pending?.origin === preResumeOrigin) {
1519
+ try {
1520
+ const pendingArtifact = decodeAndVerifyInvitationArtifact(pending.invitation);
1521
+ if (pendingArtifact.endpoint !== preResumeOrigin) {
1522
+ throw new InvitationArtifactEndpointMismatchError(preResumeOrigin, pendingArtifact.endpoint);
1523
+ }
1524
+ pendingForHost = true;
1525
+ } catch (error) {
1526
+ deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
1527
+ return { kind: 'stop', code: 1 };
1528
+ }
1529
+ }
1530
+ }
1531
+ if (pendingForHost) {
1532
+ try {
1533
+ preResumeAttempted = true;
1534
+ preResumedEnrollment = await deps.resumeServerEnrollment(preResumeOrigin, () => {
1535
+ deps.stderr(
1536
+ `Resuming the pending enrollment for \`${preResumeOrigin}\`; ` +
1537
+ 'do not enter another invitation unless the server certificate was reissued; ' +
1538
+ 'if it was, request a current invitation and rerun this command.\n',
1539
+ );
1540
+ });
1541
+ } catch {
1542
+ preResumeAttempted = false;
1543
+ }
1544
+ }
1545
+ }
1546
+
1547
+ if (artifactOnlyEnrollment || preResumeAttempted && preResumedEnrollment === null) {
1548
+ if (artifactOnlyEnrollment && deps.resumePendingServerEnrollment) {
1549
+ preResumedEnrollment = await deps.resumePendingServerEnrollment(() => {
1550
+ deps.stderr('Resuming the pending enrollment; no new invitation is required.\n');
1551
+ });
1552
+ }
1553
+ if (!(artifactOnlyEnrollment && preResumedEnrollment)) {
1554
+ prefetchedInvitation = await deps.promptSecret('Enrollment invitation (single-use; hidden input):');
1555
+ if (!prefetchedInvitation) {
1556
+ deps.stderr('No enrollment invitation was entered. Ask the server operator for one, then retry.\n');
1557
+ return { kind: 'stop', code: 1 };
1558
+ }
1559
+ try {
1560
+ prefetchedArtifact = decodeAndVerifyInvitationArtifact(prefetchedInvitation);
1561
+ } catch (error) {
1562
+ deps.stderr(`${error instanceof Error ? error.message : 'The enrollment invitation is invalid.'}\n`);
1563
+ return { kind: 'stop', code: 1 };
1564
+ }
1565
+ }
1566
+ }
1567
+
1568
+ if (!artifactOnlyEnrollment && args.flags.server === undefined && deps.defaultAuthority === undefined) {
1569
+ const connectCommand = mode === 'cube-init'
1570
+ ? 'borg server cube init --host <host>'
1571
+ : 'borg assimilate --host <host>';
1572
+ const serverInstall = await deps.ensureLocalServerInstalled(connectCommand);
1573
+ if (serverInstall !== 'present') {
1574
+ return { kind: 'stop', code: serverInstall === 'installed' ? 0 : 1 };
1575
+ }
1576
+ }
1577
+ try {
1578
+ await deps.preparePrivateRoot();
1579
+ } catch {
1580
+ deps.stderr(`${PRIVATE_STATE_UNAVAILABLE_COPY}\n`);
1581
+ return { kind: 'stop', code: 1 };
1582
+ }
1583
+
1584
+ let existing: CanonicalActiveCube | null = null;
1585
+ let hasPersistedIdentity = false;
1586
+ let localSeatReadError: unknown;
1587
+ try {
1588
+ existing = await deps.getActiveCube();
1589
+ hasPersistedIdentity = existing !== null || await deps.hasPersistedActiveCube();
1590
+ } catch (error) {
1591
+ if (error instanceof LegacySessionCredentialCollisionError) {
1592
+ return { kind: 'stop', code: reportServerFailure(deps, error.origin, error, false, mode) };
1593
+ }
1594
+ localSeatReadError = error;
1595
+ }
1596
+
1597
+ const selectedAuthority = await selectAssimilationAuthority(args.flags, deps, mode);
1598
+ if (!selectedAuthority) return { kind: 'stop', code: 1 };
1599
+ let authority = selectedAuthority;
1600
+ if (localSeatReadError !== undefined) {
1601
+ return { kind: 'stop', code: reportServerFailure(deps, authority.apiUrl, localSeatReadError, false, mode) };
1602
+ }
1603
+
1604
+ const projectRoot = repositoryContext.root;
1605
+ const wantSibling = args.flags.worktree !== undefined ||
1606
+ (!args.flags.here && !(existing === null && hasPersistedIdentity));
1607
+ let verifiedHead = '';
1608
+ if (mode !== 'cube-init' && args.flags.here && existing === null && !hasPersistedIdentity) {
1609
+ deps.stderr(
1610
+ '`borg assimilate --here` resumes this worktree\'s saved drone, but no saved drone was found.\n' +
1611
+ 'Run `borg assimilate` to create a new drone in a managed worktree.\n',
1612
+ );
1613
+ return { kind: 'stop', code: 1 };
1614
+ }
1615
+ if (mode !== 'cube-init' && wantSibling) {
1616
+ const headProbe = deps.runSync('git', ['rev-parse', '--verify', 'HEAD'], projectRoot);
1617
+ if (headProbe.status !== 0) {
1618
+ deps.stderr(
1619
+ 'sibling worktree spawn requires HEAD pointing at a commit.\n' +
1620
+ 'Create an initial commit (for example: `git commit --allow-empty -m "Initial commit"`), then rerun `borg assimilate`.\n',
1621
+ );
1622
+ return { kind: 'stop', code: 1 };
1623
+ }
1624
+ verifiedHead = headProbe.stdout.trim();
1625
+ }
1626
+
1627
+ let auth: AuthorityResolutionOutcome['auth'];
1628
+ try {
1629
+ let serverAuth: {
1630
+ token: string;
1631
+ trustIdentity: string;
1632
+ serverCapabilities?: readonly string[];
1633
+ apiUrl?: string;
1634
+ };
1635
+ if (args.flags.enroll) {
1636
+ if (!deps.isTTY()) {
1637
+ deps.stderr(
1638
+ 'Local enrollment requires an interactive operator terminal. ' +
1639
+ `Re-run ${localAssimilateCommand(authority.apiUrl, true, mode)} from the operator’s terminal.\n`,
1640
+ );
1641
+ return { kind: 'stop', code: 1 };
1642
+ }
1643
+ let resumed = preResumedEnrollment;
1644
+ if (!resumed && prefetchedArtifact === undefined && !preResumeAttempted && !artifactOnlyEnrollment) {
1645
+ resumed = await deps.resumeServerEnrollment(authority.apiUrl, () => {
1646
+ deps.stderr(
1647
+ `Resuming the pending enrollment for \`${authority.apiUrl}\`; ` +
1648
+ 'do not enter another invitation unless the server certificate was reissued; ' +
1649
+ 'if it was, request a current invitation and rerun this command.\n',
1650
+ );
1651
+ });
1652
+ }
1653
+ if (resumed) {
1654
+ if (resumed.apiUrl) authority = { kind: 'server', apiUrl: resumed.apiUrl };
1655
+ serverAuth = resumed;
1656
+ } else {
1657
+ let invitation = prefetchedInvitation ?? await deps.promptSecret(
1658
+ artifactOnlyEnrollment
1659
+ ? 'Enrollment invitation (single-use; hidden input):'
1660
+ : `Enrollment invitation for \`${authority.apiUrl}\` (single-use; hidden input):`,
1661
+ );
1662
+ if (!invitation) {
1663
+ deps.stderr(artifactOnlyEnrollment
1664
+ ? 'No enrollment invitation was entered. Ask the server operator for one, then rerun `borg assimilate --enroll`.\n'
1665
+ : `No enrollment invitation was entered for ${authority.apiUrl}. Ask the server operator for one, then rerun ${localAssimilateCommand(authority.apiUrl, true, mode)}.\n`);
1666
+ return { kind: 'stop', code: 1 };
1667
+ }
1668
+ try {
1669
+ const artifact = prefetchedArtifact ?? decodeAndVerifyInvitationArtifact(invitation);
1670
+ if (args.flags.server !== undefined && authority.apiUrl !== artifact.endpoint) {
1671
+ throw new InvitationArtifactEndpointMismatchError(authority.apiUrl, artifact.endpoint);
1672
+ }
1673
+ authority = { kind: 'server', apiUrl: artifact.endpoint };
1674
+ serverAuth = await deps.connectServer(authority.apiUrl, {
1675
+ invitation,
1676
+ artifact,
1677
+ confirmReplacement: async () => strictAffirmative(await deps.prompt(
1678
+ `A local enrollment for ${authority.apiUrl} already exists. Replacing it will orphan ` +
1679
+ 'the first enrolled client. Replace it? [y/N]: ',
1680
+ )),
1681
+ });
1682
+ } finally {
1683
+ invitation = '';
1684
+ }
1685
+ }
1686
+ deps.stderr(serverAuth.serverCapabilities?.includes('create_cube')
1687
+ ? `Owner client enrolled with \`${authority.apiUrl}\`. Creating or joining this repository’s cube next.\n`
1688
+ : `Ordinary client enrolled with \`${authority.apiUrl}\`. Checking for an accessible repository cube next.\n`);
1689
+ } else {
1690
+ serverAuth = await deps.connectServer(authority.apiUrl);
1691
+ }
1692
+ auth = {
1693
+ token: serverAuth.token,
1694
+ apiUrl: authority.apiUrl,
1695
+ serverTrustIdentity: serverAuth.trustIdentity,
1696
+ serverCapabilities: serverAuth.serverCapabilities ?? [],
1697
+ };
1698
+ if (args.flags.enroll) {
1699
+ deps.stderr(`This machine (${deps.getHostname()}) is enrolled with Borg server \`${authority.apiUrl}\`.\n`);
1700
+ }
1701
+ } catch (error) {
1702
+ return {
1703
+ kind: 'stop',
1704
+ code: reportServerFailure(deps, authority.apiUrl, error, args.flags.enroll === true, mode),
1705
+ };
1706
+ }
1707
+
1708
+ return continueAssimilation({
1709
+ authority,
1710
+ auth,
1711
+ existing,
1712
+ hasPersistedIdentity,
1713
+ projectRoot,
1714
+ wantSibling,
1715
+ verifiedHead,
1716
+ });
1717
+ }
1718
+
1719
+ export async function runAssimilate(
1720
+ args: AssimilateArgs,
1721
+ deps: AssimilateDeps,
1722
+ options: RunAssimilateOptions = {},
1723
+ ): Promise<number> {
1724
+ const repository = await resolveAssimilationRepository(args, deps);
1725
+ if (repository.kind === 'stop') return repository.code;
1726
+ const { mode, repositoryContext } = repository.value;
1727
+
1728
+ const authorityResolution = await resolveAssimilationAuthority({ args, mode, repositoryContext }, deps);
1729
+ if (authorityResolution.kind === 'stop') return authorityResolution.code;
1730
+ const {
1731
+ authority,
1732
+ auth,
1733
+ existing,
1734
+ hasPersistedIdentity,
1735
+ projectRoot,
1736
+ wantSibling,
1737
+ verifiedHead,
1738
+ } = authorityResolution.value;
1739
+
1740
+ // ----- Sprint 19 (gh#184): Reorder for strict-rollback semantics. -----
1741
+ // The previous flow created a sibling worktree (FS state) BEFORE
1742
+ // role resolution + API assimilate. Any early-return between
1743
+ // worktree-spawn and API success orphaned the worktree (gh#184
1744
+ // canonical case: unknown role arg). The new flow defers all FS
1745
+ // state until AFTER the API assimilate succeeds — early-return at
1746
+ // role resolution / listCubes / createCube / template-prompt /
1747
+ // template-invalid-choice is now structurally clean (no orphan
1748
+ // class possible). Worktree rollback narrows to local finalization failures
1749
+ // after worktree creation.
1750
+
1751
+ // Sprint 18: capture pre-chdir cwd for the post-exit shell-cd hint
1752
+ // (no chdir has happened yet; this is a stable starting point).
1753
+ const originalCwd = deps.cwd();
1754
+
1755
+ let initialized;
1756
+ try {
1757
+ initialized = await initializeRepositoryCube({
1758
+ mode,
1759
+ context: repositoryContext,
1760
+ serverOrigin: auth.apiUrl,
1761
+ flags: args.flags,
1762
+ canCreate: auth.serverCapabilities.includes('create_cube'),
1763
+ }, {
1074
1764
  isTTY: deps.isTTY,
1075
1765
  prompt: deps.prompt,
1076
1766
  write: deps.stderr,
@@ -1392,456 +2082,109 @@ export async function runAssimilate(
1392
2082
  // CR5: a verified server returned 404 for the drone endpoint — a protocol /
1393
2083
  // client-server VERSION mismatch, not a transient blip. Restarting does not
1394
2084
  // fix it; align versions. Non-destructive: no seat created, nothing reset.
1395
- deps.stderr(
1396
- `Borg reached ${authority.apiUrl} but it did not recognize this worktree's drone ` +
1397
- 'endpoint — the client and server versions are likely incompatible. No drone ' +
1398
- 'was created and nothing was changed. Update the Borg client and/or server so ' +
1399
- `their versions match, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
1400
- );
1401
- return 1;
1402
- }
1403
- if (status === 'server-failure') {
1404
- // CR5: a verified server returned 5xx — its own internal error. Transient:
1405
- // check the server, then retry. Non-destructive.
1406
- deps.stderr(
1407
- `Borg reached ${authority.apiUrl} but it returned a server error while verifying ` +
1408
- "this worktree's saved connection. No drone was created. Check the server (its logs / " +
1409
- `\`borg-mcp-server start\`), then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
1410
- );
1411
- return 1;
1412
- }
1413
- if (status === 'unreachable' || status === 'indeterminate') {
1414
- // CR5: transport failure / timeout (unreachable) or a genuinely ambiguous
1415
- // failure (indeterminate) — both transient. Start or restart the server.
1416
- deps.stderr(
1417
- `Borg could not verify this worktree's saved connection to ${authority.apiUrl}. ` +
1418
- 'No drone was created. Start or restart the server with ' +
1419
- `\`borg-mcp-server start\`, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
1420
- );
1421
- return 1;
1422
- }
1423
- if (status === 'live' && !savedLocalRole) {
1424
- deps.stderr(
1425
- `Borg verified this worktree's saved connection to ${authority.apiUrl}, but its saved ` +
1426
- 'role is unavailable. No drone was created. Ask the server operator to restore ' +
1427
- `the role, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
1428
- );
1429
- return 1;
1430
- }
1431
- reattachPriorId = existing.droneId;
1432
- remintInvalidPrior = status === 'evicted';
1433
- }
1434
- } else if (existing && args.flags.here) {
1435
- if (existing.serverTrustIdentity !== undefined || existing.apiUrl !== auth.apiUrl) {
1436
- deps.stderr(
1437
- 'This worktree\'s saved connection belongs to a different Borg authority. ' +
1438
- 'No drone was created; use a fresh worktree.\n',
1439
- );
1440
- return 1;
1441
- }
1442
- reattachPriorId = existing.droneId;
1443
- }
1444
-
1445
- if (existing && reattachPriorId !== undefined && !args.flags.force) {
1446
- const inboxPath = deps.getInboxPath(existing.cubeId, existing.droneId);
1447
- const stateRoot = monitorStateRootForWorktree(projectRoot);
1448
- const holder = (deps.inspectLiveInboxMonitor ?? inspectLiveInboxMonitor)(inboxPath, stateRoot);
1449
- if (holder !== null) {
1450
- deps.stderr(formatSeatReattachRefusal(holder, 'borg assimilate --here --force'));
1451
- return 1;
1452
- }
1453
- }
1454
-
1455
- // ----- Step 5: Role resolution -----
1456
- let resolvedRole: Role | undefined;
1457
- if (savedLocalRole) {
1458
- resolvedRole = savedLocalRole;
1459
- } else if (args.role !== undefined) {
1460
- resolvedRole = matchRoleByName(cubeDetail.roles, args.role);
1461
- if (!resolvedRole) {
1462
- // Sprint 19 (gh#184) + drone-7 metaphor argument: include a
1463
- // fuzzy-match "did you mean ...?" suggestion to serve Queen's
1464
- // "more user-friendly" intent without violating the
1465
- // Borg-collective metaphor (collective defines roles; drones
1466
- // slot in). Levenshtein distance ≤2 on the cube's role names.
1467
- const available = cubeDetail.roles.map((r) => r.name).join(', ');
1468
- const suggestion = suggestRoleName(args.role, cubeDetail.roles.map((r) => r.name));
1469
- const suggestionLine = suggestion ? ` Did you mean "${suggestion}"?` : '';
1470
- if (authority.kind === 'server') {
1471
- deps.stderr(
1472
- `No role matching "${args.role}" in cube "${cubeDetail.name}" on ${authority.apiUrl}. ` +
1473
- `Available: ${available}.${suggestionLine}\n` +
1474
- `Rerun ${localAssimilateRoleCommand(authority.apiUrl)} with one of the available roles.\n`,
1475
- );
1476
- } else {
1477
- deps.stderr(
1478
- `no role matching "${args.role}" in cube "${cubeDetail.name}". Available: ${available}.${suggestionLine}\n` +
1479
- `(Use --template <name> on first-drone setup or run \`borg_create-role\` from inside Claude.)\n`
1480
- );
1481
- }
1482
- return 1;
1483
- }
1484
- } else {
1485
- const occupiedRoleIds = occupiedRoleIdsForAutoRole(cubeDetail.drones ?? []);
1486
- resolvedRole = pickDefaultRole(cubeDetail.roles, { isFirstDrone, occupiedRoleIds });
1487
- if (!resolvedRole) {
1488
- if (authority.kind === 'server') {
1489
- deps.stderr(
1490
- `Cube "${cubeDetail.name}" on ${authority.apiUrl} has no default or human-seat role. ` +
1491
- `Ask the server operator to configure a role, then rerun ` +
1492
- `${localAssimilateRoleCommand(authority.apiUrl)}.\n`,
1493
- );
1494
- } else {
1495
- deps.stderr(
1496
- `cube "${cubeDetail.name}" has no default or human-seat role; cannot infer a role. ` +
1497
- `Either pass a role argument explicitly (e.g. \`borg assimilate builder\`) or ` +
1498
- `run \`borg_create-role\` from inside Claude to set up roles.\n`
1499
- );
1500
- }
1501
- return 1;
1502
- }
1503
- }
1504
-
1505
- // ----- Step 5b: --here collision check BEFORE the API mint (gh#780) -----
1506
- // Pre-gh#780 this check lived in Step 7 — AFTER the API assimilate — so a
1507
- // `--here` run in a directory that already hosts a drone minted a fresh
1508
- // drones row server-side, then aborted before Step 8 ever persisted the
1509
- // mapping: an orphan seat with no local identity. The check must precede
1510
- // the mint. (The full worktree DECISION stays in Step 7 by design — FS
1511
- // state only after API success; this hoists only the abort case.)
1512
- //
1513
- // PR-D refinement: --here + existing + SAME authority/cube is the
1514
- // saved-seat recovery flow. The local
1515
- // seats first prove liveness with their keychained session, then reuse the
1516
- // saved role/retry binding; only authoritative eviction rotates that retry.
1517
-
1518
- // Role defaults and local launch state do not select the model. The explicit
1519
- // Claude-only flag remains temporarily for compatibility with existing
1520
- // invocations.
1521
- const effectiveModel: string | null = args.flags.model ?? null;
1522
-
1523
- // Resolve the agent CLI now so the worker learns agent_kind AT assimilate
1524
- // time.
1525
- const cli = await deps.resolveCli(args.flags.cli);
1526
- try {
1527
- ensureCliMcpConfigured(cli);
1528
- } catch (err) {
1529
- const message = err instanceof Error ? err.message : String(err);
1530
- if (authority.kind === 'server') {
1531
- deps.stderr(
1532
- `${cli} MCP configuration failed for ${authority.apiUrl}: ${safeStderr(message)}. ` +
1533
- `Fix the ${cli} MCP configuration, then rerun ` +
1534
- `${localAssimilateCliCommand(authority.apiUrl, cli)}.\n`,
1535
- );
1536
- } else {
1537
- deps.stderr(`${cli} MCP configuration failed: ${message}\n`);
1538
- }
1539
- return 1;
1540
- }
1541
-
1542
- // The TYPED prepare-time expectation (ratified clause 3 / CR #1). Declared HERE,
1543
- // BEFORE the mint+send, and revalidated at BOTH the cube-lock-held PREPARE (so a
1544
- // reset that wins before PREPARE aborts before any credential is created/sent)
1545
- // and FINALIZE. resume/reattach/remint pin the FULL prior binding (ref + drone
1546
- // id [+ live digest]); fresh/sibling declare ABSENT.
1547
- let sessionExpected: ExpectedBinding;
1548
- if (resumeCredentialRef && resumeState === 'pending') {
1549
- // CR#2: a bound-PENDING resume (a sibling whose activation failed) re-sends the
1550
- // identical pending bearer the server already digest-bound. A PENDING record is
1551
- // NOT a live binding, so it declares ABSENT (pending-reuse): prepareSeat REUSES
1552
- // the existing pending record (identical bearer). An EXACT expectation would be
1553
- // rejected by prepareSeat's `prior.state==='active'` guard and abort the only
1554
- // ghost-free recovery.
1555
- sessionExpected = { kind: 'absent' };
1556
- } else if (resumeCredentialRef) {
1557
- sessionExpected = {
1558
- kind: 'exact',
1559
- credentialRef: resumeCredentialRef,
1560
- ...(resumeDroneId ? { droneId: resumeDroneId } : {}),
1561
- };
1562
- } else if (remintInvalidPrior && existing?.localSessionCredentialRef) {
1563
- sessionExpected = {
1564
- kind: 'exact',
1565
- credentialRef: existing.localSessionCredentialRef,
1566
- ...(existing.droneId ? { droneId: existing.droneId } : {}),
1567
- };
1568
- } else if (reattachPriorId != null && existing?.localSessionCredentialRef && existing.sessionToken) {
1569
- sessionExpected = {
1570
- kind: 'exact',
1571
- credentialRef: existing.localSessionCredentialRef,
1572
- ...(existing.droneId ? { droneId: existing.droneId } : {}),
1573
- sessionDigest: createHash('sha256').update(existing.sessionToken).digest('hex'),
1574
- };
1575
- } else {
1576
- sessionExpected = { kind: 'absent' };
1577
- }
1578
- // CR1(b): PREPARE-time revalidation is preserved for siblings too. A sibling
1579
- // declares an ABSENT expectation: a PENDING record at the ref (a lost-response
1580
- // retry / crash-in-gap) stays reusable so the identical bearer is re-sent, but an
1581
- // ACTIVE record holding the ref is a mismatch → abort (never silently reuse/move
1582
- // a live binding). With the collision-safe sibling key above the fresh ref is
1583
- // normally empty, so ABSENT passes and the mint proceeds; the check is the
1584
- // defense that stops an active seat from being unseated.
1585
- const revalidateAtPrepare = true;
1586
-
1587
- // ----- Step 6: API assimilate (no FS state yet — clean exit on failure) -----
1588
- // gh#653 B4: progress for the seat-mint round-trip (silent-window stall).
1589
- deps.stderr(`Joining cube '${cubeDetail.name}' as ${resolvedRole.name}…\n`);
1590
- let result: AssimilateResult;
1591
- try {
1592
- const assimilateParams = {
1593
- cube_id: cubeDetail.id,
1594
- role_id: resolvedRole.id,
1595
- hostname: deps.getHostname(),
1596
- agent_kind: cli,
1597
- model: effectiveModel,
1598
- working_repo: resolveWorkingRepo(projectRoot),
1599
- ...(reattachPriorId ? { prior_drone_id: reattachPriorId } : {}),
1600
- ...(remintInvalidPrior ? { remint_invalid_prior: true } : {}),
1601
- session_operation: sessionOperation,
1602
- session_expected: sessionExpected,
1603
- revalidate_at_prepare: revalidateAtPrepare,
1604
- };
1605
- result = await deps.assimilate(
1606
- auth.apiUrl,
1607
- auth.token,
1608
- assimilateParams,
1609
- auth.serverTrustIdentity,
1610
- );
1611
- } catch (err) {
1612
- // gh#877 follow-up: a re-attach (`--here`) whose saved seat was evicted is
1613
- // REFUSED server-side (410 DRONE_EVICTED) rather than silently re-minting a
1614
- // fresh drone. Surface the terminal recovery path instead of the generic
1615
- // "assimilate failed". Only on a reattach attempt (reattachPriorId set);
1616
- // a non-reattach DroneEvictedError falls through to the generic message.
1617
- if (err instanceof DroneEvictedError && reattachPriorId != null) {
1618
- deps.stderr(
1619
- `This worktree's drone on ${authority.apiUrl} was evicted. ` +
1620
- `Remove this worktree, or from a fresh worktree run ` +
1621
- `${localAssimilateCommand(authority.apiUrl)}.\n`,
1622
- );
1623
- return 1;
1624
- }
1625
- // Pin-matched terminal session outcomes are pure diagnosis.
1626
- // Reached only after a successful pinned-TLS attach, so it is pin-matched by
1627
- // construction — a pin mismatch throws a distinct trust error and never
1628
- // enters this branch. Attach mutates NOTHING; it recommends the offline
1629
- // `borg reset-local-connection` command.
1630
- if (err instanceof BorgServerError && reattachPriorId != null) {
1631
- if (err.code === 'SESSION_REVOKED') {
1632
- return diagnoseSessionTermination(deps, authority.apiUrl, 'revoked');
2085
+ deps.stderr(
2086
+ `Borg reached ${authority.apiUrl} but it did not recognize this worktree's drone ` +
2087
+ 'endpoint — the client and server versions are likely incompatible. No drone ' +
2088
+ 'was created and nothing was changed. Update the Borg client and/or server so ' +
2089
+ `their versions match, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
2090
+ );
2091
+ return 1;
1633
2092
  }
1634
- if (err.code === 'SESSION_REJECTED') {
1635
- return diagnoseSessionTermination(deps, authority.apiUrl, 'superseded');
2093
+ if (status === 'server-failure') {
2094
+ // CR5: a verified server returned 5xx — its own internal error. Transient:
2095
+ // check the server, then retry. Non-destructive.
2096
+ deps.stderr(
2097
+ `Borg reached ${authority.apiUrl} but it returned a server error while verifying ` +
2098
+ "this worktree's saved connection. No drone was created. Check the server (its logs / " +
2099
+ `\`borg-mcp-server start\`), then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
2100
+ );
2101
+ return 1;
1636
2102
  }
1637
- }
1638
- if (authority.kind === 'server') {
1639
- return reportServerFailure(deps, authority.apiUrl, err);
1640
- }
1641
- const message = err instanceof Error ? err.message : String(err);
1642
- deps.stderr(`assimilate failed: ${message}\n`);
1643
- return 1;
1644
- }
1645
-
1646
- if (authority.kind === 'server' && result.prepareAborted) {
1647
- // CR #1: the cube-lock-held PREPARE revalidation aborted BEFORE any credential
1648
- // was minted or sent — this worktree's saved seat changed under us (a
1649
- // concurrent offline reset, or a competing enroll). No FS/network mutation
1650
- // happened; never silently recreate.
1651
- deps.stderr(
1652
- `This worktree's saved connection to ${authority.apiUrl} changed before the attach ` +
1653
- '(a concurrent reset or enroll); no credential was created or sent and nothing was ' +
1654
- `changed. Re-run ${localAssimilateCommand(authority.apiUrl)} to attach against the ` +
1655
- 'current state.\n',
1656
- );
1657
- return 1;
1658
- }
1659
-
1660
- if (authority.kind === 'server' && result.local_session === undefined) {
1661
- return reportServerFailure(
1662
- deps,
1663
- authority.apiUrl,
1664
- new Error('Borg server did not return compatible secure session metadata'),
1665
- );
1666
- }
1667
- // The server may assimilate a member into a DIFFERENT role than the client's
1668
- // auto-picked default (gh#700 fallback: when the member's invite doesn't
1669
- // grant the default role, the server picks one of their GRANTED roles).
1670
- // Resolve the role the SERVER ACTUALLY assigned (result.role_id) and use it
1671
- // for all human-facing display + naming below — not the client's pre-pick.
1672
- // The drone label / session token are already server-truth; this aligns the
1673
- // displayed role name + worktree slug with what was actually assigned.
1674
- const assignedRole =
1675
- cubeDetail.roles.find((r) => r.id === result.role_id) ?? resolvedRole;
1676
- if (result.result === 'reused') {
1677
- // The drone's existing role is authoritative on an idempotent reattach —
1678
- // a role difference is expected, not a grant fallback. The bearer is
1679
- // reused, not rotated: no new drone minted.
1680
- deps.stderr(
1681
- `re-attached as ${result.drone_label} (same session, no new drone minted)\n`
1682
- );
1683
- } else if (assignedRole.id !== resolvedRole.id) {
1684
- deps.stderr(
1685
- `The requested role "${resolvedRole.name}" was unavailable; ` +
1686
- `attached under the "${assignedRole.name}" role instead.\n`
1687
- );
1688
- }
1689
-
1690
- // ----- Step 7: Worktree decision (FS state ONLY after API success) -----
1691
- // (`existing` was read at Step 5b; a different-cube --here collision
1692
- // already aborted there, pre-mint. The surviving --here + existing case
1693
- // is the SAME-cube reattach — an in-place recovery, never a sibling
1694
- // spawn.)
1695
- let spawnedWorktreePath: string | null = null;
1696
-
1697
- if (wantSibling) {
1698
- const localHead = verifiedHead;
1699
- const originProbe = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
1700
- let startRef = 'HEAD';
1701
- if (originProbe.status === 0 && originProbe.stdout.trim().length > 0) {
1702
- // gh#238: when origin exists, fetch it so the new worktree starts on the
1703
- // latest remote default branch rather than a possibly stale local HEAD.
1704
- deps.runSync('git', ['fetch', 'origin'], projectRoot);
1705
-
1706
- const mainProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/main'], projectRoot);
1707
- if (mainProbe.status === 0) {
1708
- startRef = 'origin/main';
1709
- } else {
1710
- const masterProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/master'], projectRoot);
1711
- if (masterProbe.status === 0) {
1712
- startRef = 'origin/master';
1713
- }
2103
+ if (status === 'unreachable' || status === 'indeterminate') {
2104
+ // CR5: transport failure / timeout (unreachable) or a genuinely ambiguous
2105
+ // failure (indeterminate) — both transient. Start or restart the server.
2106
+ deps.stderr(
2107
+ `Borg could not verify this worktree's saved connection to ${authority.apiUrl}. ` +
2108
+ 'No drone was created. Start or restart the server with ' +
2109
+ `\`borg-mcp-server start\`, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
2110
+ );
2111
+ return 1;
1714
2112
  }
1715
- }
1716
-
1717
- if (startRef === 'HEAD') {
1718
- deps.stderr(
1719
- `note: no usable origin; new worktree will start on local HEAD (${localHead.slice(0, 7)})\n`
1720
- );
1721
- } else {
1722
- // Warn if local HEAD diverges from the remote default branch.
1723
- const remoteHead = deps.runSync('git', ['rev-parse', startRef], projectRoot).stdout.trim();
1724
- if (localHead !== remoteHead) {
2113
+ if (status === 'live' && !savedLocalRole) {
1725
2114
  deps.stderr(
1726
- `note: local HEAD (${localHead.slice(0, 7)}) differs from ${startRef} (${remoteHead.slice(0, 7)}); ` +
1727
- `new worktree will start on ${startRef}\n`
2115
+ `Borg verified this worktree's saved connection to ${authority.apiUrl}, but its saved ` +
2116
+ 'role is unavailable. No drone was created. Ask the server operator to restore ' +
2117
+ `the role, then rerun ${localAssimilateCommand(authority.apiUrl)}.\n`,
1728
2118
  );
2119
+ return 1;
1729
2120
  }
2121
+ reattachPriorId = existing.droneId;
2122
+ remintInvalidPrior = status === 'evicted';
1730
2123
  }
1731
-
1732
- // The common Git directory identifies the repository across every linked
1733
- // worktree. Using projectRoot here fragments one repository's siblings when
1734
- // assimilation starts inside an existing sibling worktree.
1735
- const repoBase = basename(dirname(repositoryContext.commonDir));
1736
- const suffix = args.flags.worktree ?? roleSlug(assignedRole.name);
1737
- // gh#556 Part 1: empty-suffix guard (CR-binding). roleSlug can yield '' for a
1738
- // pathological all-special-char role name; an empty leaf would let join() collapse
1739
- // the worktree path up to the repo-level dir (~/.borg/worktrees/<repo>) and spawn a
1740
- // worktree at the parent-of-all-this-repo's-worktrees. Fail loud BEFORE the path calc.
1741
- if (suffix.length === 0) {
1742
- deps.stderr(
1743
- `cannot derive a worktree name from role "${assignedRole.name}"; ` +
1744
- `pass an explicit --worktree <name>\n`
1745
- );
1746
- return 1;
1747
- }
1748
- // gh#556 Part 1: NEW worktrees live under ~/.borg/worktrees/<repo>/<name>
1749
- // (was a sibling <parent>/<repo>-<name>). Existing siblings are untouched
1750
- // (absolute git-registered paths). Collision dedup KEPT (<name>-<n>).
1751
- const homeDir = deps.homedir();
1752
- let registeredWorktrees = listRegisteredWorktrees(deps, projectRoot);
1753
- if (registeredWorktrees === null) {
2124
+ } else if (existing && args.flags.here) {
2125
+ if (existing.serverTrustIdentity !== undefined || existing.apiUrl !== auth.apiUrl) {
1754
2126
  deps.stderr(
1755
- 'Borg could not enumerate this repository’s existing worktrees, so it did not risk creating a colliding sibling.\n' +
1756
- 'Run `git worktree list` from this repository and resolve the reported Git error, then rerun `borg assimilate`.\n' +
1757
- 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n',
2127
+ 'This worktree\'s saved connection belongs to a different Borg authority. ' +
2128
+ 'No drone was created; use a fresh worktree.\n',
1758
2129
  );
1759
2130
  return 1;
1760
2131
  }
1761
- let candidate = computeWorktreePath(homeDir, repoBase, suffix);
1762
- let wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1763
- let n = 2;
1764
- // gh#864: dedup against an existing worktree PATH/registration AND against a
1765
- // lingering UNMERGED per-worktree branch. `git worktree add -b <wtBranch>`
1766
- // (below) hard-fails when <wtBranch> already exists even if its old worktree
1767
- // was pruned — so a stale ref would block the spawn. A MERGED lingering
1768
- // branch is safely adoptable (handled at the add), so it does NOT force a
1769
- // suffix bump; only an UNMERGED ref (carrying un-merged commits) bumps to a
1770
- // fresh suffix so we never reuse/clobber its work.
1771
- while (
1772
- deps.pathExists(candidate) ||
1773
- registeredWorktrees.names.has(basename(candidate)) ||
1774
- registeredWorktrees.branches.has(wtBranch) ||
1775
- (localBranchExists(deps.runSync, projectRoot, wtBranch) &&
1776
- !isMerged(deps.runSync, projectRoot, wtBranch, startRef))
1777
- ) {
1778
- candidate = computeWorktreePath(homeDir, repoBase, suffix, n);
1779
- wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1780
- n++;
1781
- }
1782
- let wt: ReturnType<AssimilateDeps['runSync']>;
1783
- let residualBranch: string | null = null;
1784
- while (true) {
1785
- // gh#556 Part 1: create the intermediate ~/.borg/worktrees/<repo>/ before
1786
- // `git worktree add` (git creates the leaf, not the parent chain). Plain
1787
- // recursive mkdir — NO chmod of the existing ~/.borg (credentials file).
1788
- deps.mkdirp(dirname(candidate));
1789
- const branchExisted = localBranchExists(deps.runSync, projectRoot, wtBranch);
1790
- wt = branchExisted
1791
- ? deps.runSync('git', ['worktree', 'add', candidate, wtBranch], projectRoot)
1792
- : deps.runSync('git', ['worktree', 'add', '-b', wtBranch, candidate, startRef], projectRoot);
1793
- if (wt.status === 0) break;
1794
-
1795
- // Another assimilate may claim the name or branch after our first list.
1796
- // Refresh and suffix-bump instead of surfacing a collision to the operator.
1797
- const refreshed = listRegisteredWorktrees(deps, projectRoot);
1798
- const branchAppeared = !branchExisted && localBranchExists(deps.runSync, projectRoot, wtBranch);
1799
- const collision =
1800
- deps.pathExists(candidate) ||
1801
- refreshed?.names.has(basename(candidate)) === true ||
1802
- refreshed?.branches.has(wtBranch) === true ||
1803
- (!branchExisted && worktreeAddReportedCollision(wt.stderr));
1804
- if (!collision || refreshed === null) {
1805
- if (branchAppeared && refreshed?.branches.has(wtBranch) !== true) {
1806
- residualBranch = wtBranch;
1807
- }
1808
- break;
1809
- }
1810
- registeredWorktrees = refreshed;
1811
- do {
1812
- candidate = computeWorktreePath(homeDir, repoBase, suffix, n);
1813
- wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
1814
- n++;
1815
- } while (
1816
- deps.pathExists(candidate) ||
1817
- registeredWorktrees.names.has(basename(candidate)) ||
1818
- registeredWorktrees.branches.has(wtBranch) ||
1819
- localBranchExists(deps.runSync, projectRoot, wtBranch)
1820
- );
1821
- }
1822
- if (wt.status !== 0) {
1823
- deps.stderr(
1824
- `Borg could not create sibling worktree ${candidate} on branch ${wtBranch}. ` +
1825
- `Git reported: ${safeStderr(wt.stderr)}\n` +
1826
- (residualBranch
1827
- ? `Git left branch ${residualBranch} without a registered worktree; Borg preserved it.\n`
1828
- : '') +
1829
- 'Run `git worktree list` and `git status` to inspect repository state, resolve the reported Git error, then rerun `borg assimilate`.\n' +
1830
- 'A local drone reservation was created and remains pending; rerunning after fixing the worktree issue resumes that reservation.\n',
1831
- );
2132
+ reattachPriorId = existing.droneId;
2133
+ }
2134
+
2135
+ if (existing && reattachPriorId !== undefined && !args.flags.force) {
2136
+ const inboxPath = deps.getInboxPath(existing.cubeId, existing.droneId);
2137
+ const stateRoot = monitorStateRootForWorktree(projectRoot);
2138
+ const holder = (deps.inspectLiveInboxMonitor ?? inspectLiveInboxMonitor)(inboxPath, stateRoot);
2139
+ if (holder !== null) {
2140
+ deps.stderr(formatSeatReattachRefusal(holder, 'borg assimilate --here --force'));
1832
2141
  return 1;
1833
2142
  }
1834
- deps.stderr(
1835
- `spawned sibling worktree at ${candidate} on branch ${wtBranch} (${startRef})` +
1836
- (existing !== null
1837
- ? `; the original dir keeps its active drone binding — run \`borg reset-local-connection\` there if that binding is stale.\n`
1838
- : '.\n'),
1839
- );
1840
- deps.chdir(candidate);
1841
- deps.stderr(renderWorktreeSteeringNote(candidate, wtBranch, projectRoot));
1842
- spawnedWorktreePath = deps.cwd();
1843
2143
  }
1844
2144
 
2145
+ const cubeRole = await resolveAssimilationCubeRole({
2146
+ requestedRole: args.role,
2147
+ flags: args.flags,
2148
+ cubeDetail,
2149
+ isFirstDrone,
2150
+ savedLocalRole,
2151
+ apiUrl: authority.apiUrl,
2152
+ }, deps);
2153
+ if (cubeRole.kind === 'stop') return cubeRole.code;
2154
+ const { resolvedRole, effectiveModel, cli } = cubeRole.value;
2155
+
2156
+ const seat = await prepareAssimilationSeat({
2157
+ apiUrl: auth.apiUrl,
2158
+ token: auth.token,
2159
+ serverTrustIdentity: auth.serverTrustIdentity,
2160
+ cubeDetail,
2161
+ resolvedRole,
2162
+ cli,
2163
+ effectiveModel,
2164
+ projectRoot,
2165
+ existing,
2166
+ reattachPriorId,
2167
+ remintInvalidPrior,
2168
+ resumeCredentialRef,
2169
+ resumeDroneId,
2170
+ resumeState,
2171
+ sessionOperation,
2172
+ }, deps);
2173
+ if (seat.kind === 'stop') return seat.code;
2174
+ const { result, assignedRole, sessionExpected } = seat.value;
2175
+
2176
+ const worktree = await prepareAssimilationWorktree({
2177
+ flags: args.flags,
2178
+ repositoryContext,
2179
+ projectRoot,
2180
+ wantSibling,
2181
+ verifiedHead,
2182
+ assignedRole,
2183
+ existing,
2184
+ }, deps);
2185
+ if (worktree.kind === 'stop') return worktree.code;
2186
+ const { spawnedWorktreePath } = worktree.value;
2187
+
1845
2188
  // ----- Step 7b: provision launch access before persisting/launching -----
1846
2189
  // The launched process gets its current worktree plus a stable, disposable
1847
2190
  // per-seat scratch root. Codex also receives an external Git common directory
@@ -1920,118 +2263,15 @@ export async function runAssimilate(
1920
2263
  return 1;
1921
2264
  }
1922
2265
 
1923
- // Local-server authority: drive the COMPOSITE cube-owned FINALIZE (Race 2).
1924
- // The cube lock is held OUTER across revalidate → binding-write → activate; the
1925
- // typed expectation is declared HERE at the orchestration layer (reattach =
1926
- // EXACT prior binding with its live-bearer digest; eviction remint = EXACT ref
1927
- // only, bearer intentionally replaced; fresh/sibling = ABSENT).
1928
- if (result.finalize === undefined || deps.finalizeServerSeat === undefined) {
1929
- deps.stderr('Local Borg server session metadata is incomplete; no connection was saved.\n');
1930
- rollbackWorktree();
1931
- return 1;
1932
- }
1933
- {
1934
- // The SAME typed expectation declared before PREPARE is revalidated again at
1935
- // FINALIZE (commit-time revalidation, ratified clause 3).
1936
- let outcome: FinalizeServerSeatOutcome;
1937
- try {
1938
- outcome = await deps.finalizeServerSeat({
1939
- active: activeCube,
1940
- commonDir: repositoryContext.commonDir,
1941
- ...(repositoryContext.publicRepository
1942
- ? { repositoryOrigin: repositoryContext.publicRepository.value }
1943
- : {}),
1944
- expected: sessionExpected,
1945
- activate: result.finalize.activate,
1946
- scrubPending: result.finalize.scrubPending,
1947
- });
1948
- } catch (err) {
1949
- // A BINDING-WRITE (or revalidate) failure BEFORE the binding landed. Nothing
1950
- // owns the spawned worktree yet, so rolling it back is safe.
1951
- const message = err instanceof Error ? err.message : String(err);
1952
- deps.stderr(`finalizeServerSeat failed: ${message}\n`);
1953
- rollbackWorktree();
1954
- return 1;
1955
- }
1956
- if (!outcome.committed) {
1957
- if (outcome.reason === 'activation-failed') {
1958
- // CR #5: the atomic activate+bind did NOT commit (missing/replaced/threw), so
1959
- // the record stays PENDING with no worktree of its own. CR#2/CR#4: bind that
1960
- // exact pending record to THIS preserved worktree WITHOUT activating it — the
1961
- // record stays pending (non-hydratable) but becomes DISCOVERABLE from here, so
1962
- // a rerun FROM this worktree re-derives the exact original operation and
1963
- // re-sends the identical bearer, converging on the SAME seat (no ghost).
1964
- //
1965
- // CR#4 (SR-seven false-success revocation): the bindPending OUTCOME is
1966
- // load-bearing and must be BRANCHED. A blanket "safe to re-run / identical
1967
- // seat reused" claim on a missing/replaced/thrown bind is a FALSE-SUCCESS
1968
- // revocation failure — the worktree would NOT own a durable locator, yet the
1969
- // operator would be told convergence is guaranteed. Preserve the spawned
1970
- // worktree ONLY when it owns a durable locator (a `bound` outcome).
1971
- let bindOutcome: BindPendingSeatOutcome | 'threw' | 'unavailable' = 'unavailable';
1972
- if (result.finalize?.bindPending) {
1973
- try {
1974
- bindOutcome = (await result.finalize.bindPending({
1975
- worktree: deps.findProjectRoot(deps.cwd()),
1976
- name: activeCube.name,
1977
- droneLabel: activeCube.droneLabel,
1978
- ...(activeCube.roleName !== undefined ? { roleName: activeCube.roleName } : {}),
1979
- ...(activeCube.roleClass !== undefined ? { roleClass: activeCube.roleClass } : {}),
1980
- ...(activeCube.isHumanSeat !== undefined ? { isHumanSeat: activeCube.isHumanSeat } : {}),
1981
- })) as BindPendingSeatOutcome;
1982
- } catch {
1983
- bindOutcome = 'threw';
1984
- }
1985
- }
1986
- if (bindOutcome === 'bound') {
1987
- // The worktree now owns a durable locator (the bound-pending record points
1988
- // here). PRESERVE it. Truthful convergence copy: a rerun FROM here re-sends
1989
- // the identical bearer (no duplicate), and `reset-local-connection` from here now
1990
- // discovers + clears the bound-pending record.
1991
- deps.stderr(
1992
- `This worktree's secure session on ${auth.apiUrl} did not finish activating, but ` +
1993
- 'its resumable connection state was PRESERVED here. This worktree was NOT removed. From ' +
1994
- `here, re-run ${localAssimilateCommand(auth.apiUrl)} to converge (the identical connection ` +
1995
- `is reused — no duplicate is minted), or run ${resetLocalSeatCommand(auth.apiUrl)} to ` +
1996
- 'clear it.\n',
1997
- );
1998
- return 1;
1999
- }
2000
- // missing / replaced / threw / unavailable: the worktree owns NO durable
2001
- // locator. The server may already have accepted the seat, while the client
2002
- // has no protocol operation id or cleanup endpoint with which to prove reuse
2003
- // or remove it. State the exact local outcome and do not prescribe a retry
2004
- // that can silently create a duplicate server seat (#35).
2005
- const bindFailure =
2006
- bindOutcome === 'missing'
2007
- ? 'the exact pending connection record went missing locally before it could be bound'
2008
- : bindOutcome === 'replaced'
2009
- ? 'the exact pending connection record was replaced locally before it could be bound; the replacement was left untouched'
2010
- : bindOutcome === 'threw'
2011
- ? 'the private store could not be read or written while preserving the pending connection'
2012
- : 'this client did not receive a pending-connection preservation handle';
2013
- deps.stderr(
2014
- `This worktree's secure session on ${auth.apiUrl} did not finish activating: ` +
2015
- `${bindFailure}. The spawned worktree will be removed. No client-only command can ` +
2016
- 'prove reuse or safely clear the possibly accepted server-side drone; ask the server ' +
2017
- 'operator to inspect that drone before retrying.\n',
2018
- );
2019
- rollbackWorktree();
2020
- return 1;
2021
- }
2022
- // 'expectation-mismatch': the binding was NEVER written (this worktree's
2023
- // saved seat changed under us between PREPARE and FINALIZE — a concurrent
2024
- // reset or enroll). The composite scrubbed only our own pending record — no
2025
- // orphan ACTIVE credential — so a just-spawned worktree is safe to remove.
2026
- deps.stderr(
2027
- `This worktree's saved connection to ${auth.apiUrl} changed during attach ` +
2028
- '(a concurrent reset or enroll); no drone was created and nothing was overwritten. ' +
2029
- `Re-run ${localAssimilateCommand(auth.apiUrl)} to attach against the current state.\n`,
2030
- );
2031
- rollbackWorktree();
2032
- return 1;
2033
- }
2034
- }
2266
+ const finalization = await finalizeAssimilationSeat({
2267
+ activeCube,
2268
+ apiUrl: auth.apiUrl,
2269
+ repositoryContext,
2270
+ result,
2271
+ sessionExpected,
2272
+ rollbackWorktree,
2273
+ }, deps);
2274
+ if (finalization.kind === 'stop') return finalization.code;
2035
2275
 
2036
2276
  if (repositoryContext.publicRepository && deps.hasActiveSeatInDifferentCloneFamily) {
2037
2277
  try {
@@ -2095,211 +2335,23 @@ export async function runAssimilate(
2095
2335
  });
2096
2336
  if (options.launch === false) return 0;
2097
2337
 
2098
- // ----- Step 8: Launch selected agent CLI -----
2099
- // Mirrors the kickoff invocation from claude.ts (no-args path): the agent
2100
- // picks up the newly-persisted ActiveCube via the MCP stdio server on
2101
- // startup. The kickoff prompt re-enters /loop borg_regen so the new
2102
- // drone bootstraps into the cube cleanly. The monitor clause (CR-PE-F1)
2103
- // arms the inbox tail so the new drone wakes on peer log entries in
2104
- // real time — without this, drones miss real-time wake events during
2105
- // the bootstrap window and only self-heal at the /loop heartbeat.
2106
- deps.setTerminalTitle(result.drone_label, cubeDetail.name);
2107
-
2108
- // Pedagogical hint to stdout before Claude takes over the terminal.
2109
- // Ink does not enter alt-screen-buffer (verified empirically via PTY
2110
- // probe 2026-05-19), so lines printed here remain visible in the
2111
- // user's terminal scrollback above Claude's interactive UI. Color is
2112
- // gated on TTY + NO_COLOR/CI env-var conventions; the welcome shape
2113
- // itself is cube-agnostic so non-default templates render identically.
2114
- const useColor = deps.isTTY() && !process.env.NO_COLOR && !process.env.CI;
2115
- deps.stdout(
2116
- renderAssimilationWelcome(
2117
- result.drone_label,
2118
- assignedRole.name,
2119
- cubeDetail.name,
2120
- useColor,
2121
- authority.kind === 'server' ? authority.apiUrl : undefined,
2122
- ),
2123
- );
2124
-
2125
- // BUG-5 / v0.9.3: probe MCP readiness before launching claude so
2126
- // the launched session sees tools at startup. Non-blocking: probe
2127
- // failure surfaces a stderr warning but the launch proceeds (the
2128
- // kickoff text's ToolSearch recovery clause is the second line of
2129
- // defense).
2130
- const mcpReady = await deps.probeMcpReady();
2131
- if (!mcpReady) {
2132
- deps.stderr(
2133
- `warning: borg-mcp readiness probe did not complete within the timeout; ` +
2134
- `launching ${cli} anyway — the kickoff prompt's ToolSearch fallback ` +
2135
- `will recover if the MCP server takes longer to start.\n`
2136
- );
2137
- }
2138
- const inboxPath = deps.getInboxPath(result.cube_id, result.drone_id);
2139
- const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
2140
- // gh#929: shared wakePathArming + NEVER-TaskStop (unified with claude.ts —
2141
- // the two call sites previously carried divergent monitorClause strings).
2142
- const monitorClause = buildKickoffWakePathClause(
2143
- cli,
2144
- cli === 'claude' ? inboxPath : null,
2145
- cli === 'claude' ? monitorStateRoot : null
2146
- );
2147
- let codexWakePathClause: string | undefined;
2148
- let remoteArgs: string[] = [];
2149
- let launchArgs: string[];
2150
- let codexSocketPath: string | null = null;
2151
- let codexServerCleanup: (() => void) | null = null;
2152
- const launchApproval = deps.resolveCliApprovals
2153
- ? await deps.resolveCliApprovals(cli, agentCwd, {
2154
- skipOverride: args.flags.noBorgApprovalOverride,
2155
- })
2156
- : { codexArgs: [] };
2157
- if (launchApproval.warning) deps.stderr(`warning: ${launchApproval.warning}\n`);
2158
-
2159
- // Temporary Claude-only model compatibility. Local/provider models are
2160
- // configured by the selected agent CLI and are never rewritten by Borg.
2161
- const modelEnv = resolveLaunchEnv(effectiveModel);
2162
- const childEnv: Record<string, string> = {
2163
- ...(withAgentRuntimeEnv(process.env, cli) as Record<string, string>),
2164
- ...modelEnv.set,
2165
- BORG_SESSION: '1',
2166
- [BORG_LAUNCH_CLI_ENV]: cli,
2167
- [BORG_LAUNCH_WORKTREE_ENV]: seatWorktree,
2168
- [BORG_LAUNCH_SCRATCH_ENV]: scratchRoot,
2169
- };
2170
- if (cli === 'opencode' && launchApproval.openCodePermission) {
2171
- childEnv.OPENCODE_PERMISSION = launchApproval.openCodePermission;
2172
- }
2173
- for (const key of modelEnv.unset) {
2174
- delete childEnv[key];
2175
- }
2176
-
2177
- if (cli === 'codex') {
2178
- const remote = await deps.prepareCodexRemoteLaunch();
2179
- if (remote.warning) {
2180
- deps.stderr(`warning: ${remote.warning}\n`);
2181
- codexWakePathClause =
2182
- `⚠ Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg_regen manually whenever you return, and expect only fallback wakeups until relaunch.`;
2183
- } else {
2184
- codexWakePathClause =
2185
- `Codex wake-path capability check passed: remote-control socket established for this session.`;
2186
- }
2187
- remoteArgs = remote.args;
2188
- // Codex env takes precedence over model env when there is overlap.
2189
- if (Object.keys(remote.env).length > 0) {
2190
- Object.assign(childEnv, remote.env);
2191
- }
2192
- codexSocketPath = socketPathFromRemoteArgs(remote.args);
2193
- codexServerCleanup = remote.server?.cleanup ?? null;
2194
- }
2195
- const kickoff = buildAgentKickoffPrompt({
2338
+ return launchAssimilatedAgent({
2339
+ flags: args.flags,
2340
+ result,
2341
+ cubeDetail,
2342
+ assignedRole,
2343
+ apiUrl: auth.apiUrl,
2196
2344
  cli,
2197
- codexWakeNonce,
2198
- monitorClause,
2199
- codexWakePathClause,
2200
- });
2201
- // Keep launch trust separate from the shared kickoff. OpenCode receives the
2202
- // same prompt text; its plugin adds the correlation identity to hidden
2203
- // TextPart metadata instead of argv or prompt content.
2204
- let openCodeKickoff: ReturnType<typeof createOpenCodeLaunchKickoff> | null = null;
2205
- let dronePort: number | undefined;
2206
- launchArgs = [kickoff];
2207
- if (cli === 'codex') {
2208
- // gh#673 P1-codex: -c overrides deliver BORG_SESSION and the selected
2209
- // CLI identity to the codex-spawned borg-mcp child (inherited env never
2210
- // reaches Codex MCP children — V2/V2b probes). Explicitly pin remote wake
2211
- // off when no socket is available, overriding legacy static configs that
2212
- // formerly used this transport marker as Codex identity.
2213
- launchArgs = [
2214
- ...codexLaunchDirectoryArgs(launchAccessPaths),
2215
- ...launchApproval.codexArgs,
2216
- ...codexBorgSessionConfigArgs(),
2217
- ...codexAgentKindConfigArgs(),
2218
- ...codexRemoteWakeConfigArgs(codexSocketPath !== null),
2219
- ...codexStateRootConfigArgs(),
2220
- ...remoteArgs,
2221
- ...withCodexCwdArg(launchArgs, agentCwd),
2222
- ];
2223
- } else if (cli === 'opencode') {
2224
- // OpenCode assimilate launch: start TUI with the kickoff passed via
2225
- // --prompt (auto-submits it as the first message). BORG_SESSION is
2226
- // pinned in opencode.json. An OS-selected launch-scoped port is shared
2227
- // with the MCP child for local HTTP entry injection.
2228
- dronePort = await allocateOpenCodePort();
2229
- childEnv.BORG_OPENCODE_PORT = String(dronePort);
2230
- installBorgPlugin();
2231
- const cwd = agentCwd;
2232
- openCodeKickoff = createOpenCodeLaunchKickoff(kickoff);
2233
- childEnv[OPENCODE_SERVER_USERNAME_ENV] = OPENCODE_SERVER_USERNAME;
2234
- childEnv[OPENCODE_SERVER_PASSWORD_ENV] = openCodeKickoff.apiPassword;
2235
- childEnv[BORG_OPENCODE_LAUNCH_CORRELATION_ENV] = openCodeKickoff.correlationIdentity;
2236
- launchArgs = buildOpenCodeLaunchArgs(cwd, dronePort, openCodeKickoff.prompt);
2237
- }
2238
- // gh#673 P1: mark the launched agent session as borg-launched so the
2239
- // MCP child + hook bins activate (launch-gate.ts). childEnv is the
2240
- // complete child environment (process.env + model.set, minus unset
2241
- // keys, plus BORG_SESSION + codex env). The exec seam must use it
2242
- // directly without re-merging process.env (assimilate-deps.ts).
2243
- const exitPromise = deps.exec(cli, launchArgs, agentCwd, childEnv);
2244
- if (cli === 'codex' && codexSocketPath && codexWakeNonce) {
2245
- void recordCodexWakeTarget({
2246
- deps,
2247
- cubeId: result.cube_id,
2248
- droneId: result.drone_id,
2249
- socketPath: codexSocketPath,
2250
- cwd: agentCwd,
2251
- previewNeedle: codexWakeNonce,
2252
- launchedAtSeconds: Math.floor(Date.now() / 1000),
2253
- });
2254
- }
2255
- // gh#opencode: bind to the kickoff-bearing session through OpenCode's local
2256
- // HTTP API after the TUI auto-submits --prompt. Best-effort.
2257
- if (cli === 'opencode' && openCodeKickoff) {
2258
- const launchKickoff = openCodeKickoff;
2259
- // The port is checked before spawn but cannot be reserved through
2260
- // OpenCode's own bind. The residual allocation-to-spawn race is tracked
2261
- // in client#298; this slice only establishes deterministic rendezvous.
2262
- const serverUrl = `http://127.0.0.1:${dronePort}`;
2263
- connectOpenCodeDrone({
2264
- serverUrl,
2265
- apiPassword: launchKickoff.apiPassword,
2266
- directory: agentCwd,
2267
- droneLabel: result.drone_label,
2268
- cubeName: cubeDetail.name,
2269
- launchIdentity: launchKickoff.correlationIdentity,
2270
- })
2271
- .then(() => injectInitialKickoff(launchKickoff))
2272
- .catch(() => {});
2273
- }
2274
- const exitCode = await exitPromise;
2275
- // gh#528: kill the borg-owned Codex app-server when the assimilate-launched
2276
- // session exits, so it isn't left orphaned (live → not pruned by pid liveness).
2277
- // OpenCode has no app-server to clean up.
2278
- if (codexServerCleanup) {
2279
- try {
2280
- codexServerCleanup();
2281
- } catch {
2282
- // best-effort
2283
- }
2284
- }
2345
+ effectiveModel,
2346
+ agentCwd,
2347
+ seatWorktree,
2348
+ scratchRoot,
2349
+ launchAccessPaths,
2350
+ monitorStateRoot,
2351
+ spawnedWorktreePath,
2352
+ originalCwd,
2353
+ }, deps);
2285
2354
 
2286
- // Sprint 18: when a sibling worktree was spawned, the user's shell
2287
- // returns to their original cwd after Claude exits (process.chdir
2288
- // doesn't propagate to the parent). Emit a stderr hint so they know
2289
- // how to get back into the worktree. shellEscape defangs any shell
2290
- // metachars in the path against paste-injection (drone-11 SR-LANE).
2291
- // Skip the hint when no worktree was spawned (--here / no-worktree
2292
- // flow) or when originalCwd already matches the worktree path
2293
- // (defensive against the no-op edge case drone-9 UX-LANE flagged).
2294
- if (spawnedWorktreePath && originalCwd !== spawnedWorktreePath) {
2295
- deps.stderr(
2296
- `\nAgent exited. You were working in ${spawnedWorktreePath}; your shell is back in ${originalCwd}.\n` +
2297
- `To return:\n` +
2298
- ` cd ${shellEscape(spawnedWorktreePath)}\n`
2299
- );
2300
- }
2301
-
2302
- return exitCode;
2303
2355
  }
2304
2356
 
2305
2357
  function renderWorktreeSteeringNote(worktreePath: string, wtBranch: string, primaryPath: string): string {
@@ -2336,7 +2388,7 @@ function worktreeAddReportedCollision(stderr: string): boolean {
2336
2388
  }
2337
2389
 
2338
2390
  function listRegisteredWorktrees(
2339
- deps: AssimilateDeps,
2391
+ deps: Pick<AssimilateDeps, 'runSync'>,
2340
2392
  projectRoot: string,
2341
2393
  ): { names: Set<string>; branches: Set<string> } | null {
2342
2394
  const res = deps.runSync('git', ['worktree', 'list', '--porcelain'], projectRoot);