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.
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/assimilate-cmd.d.ts +115 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +611 -761
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/server-handshake.d.ts +4 -4
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +3 -3
- package/dist/server-handshake.js.map +1 -1
- package/docs/RELEASING.md +2 -2
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +1108 -1056
- package/src/server-handshake.ts +4 -4
package/src/assimilate-cmd.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
722
|
-
|
|
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
|
|
732
|
-
if (!
|
|
733
|
-
deps.stderr(
|
|
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
|
-
|
|
767
|
-
|
|
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
|
-
|
|
777
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
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
|
-
|
|
849
|
+
if (outcome.reason === 'activation-failed') {
|
|
850
|
+
let bindOutcome: BindPendingSeatOutcome | 'threw' | 'unavailable' = 'unavailable';
|
|
851
|
+
if (result.finalize.bindPending) {
|
|
847
852
|
try {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
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
|
-
|
|
938
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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 (
|
|
1635
|
-
|
|
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
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
`
|
|
1727
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
1756
|
-
|
|
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
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
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);
|