You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/14 07:24:33 UTC

(maven) branch master updated: [MNG-8547] Expose repository events through the Maven API

This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git


The following commit(s) were added to refs/heads/master by this push:
     new c7b164f761 [MNG-8547] Expose repository events through the Maven API
c7b164f761 is described below

commit c7b164f76189be25a2c6a48ffe74ff3afc39fcc2
Author: Goutam Adwant <86...@users.noreply.github.com>
AuthorDate: Mon Sep 14 00:24:27 2026 -0700

    [MNG-8547] Expose repository events through the Maven API
    
    [MNG-8547] Expose repository events through the Maven API
    
    Add Maven API repository event and listener types and bridge all Resolver
    repository callbacks without exposing Resolver types.
    
    New API types:
    - Event: base interface for all events, with session() accessor and
      deprecated getSession() compat default
    - ExecutionEvent: extends Event, moves to maven-api-core with noun-style
      accessors (project(), mojoExecution(), etc.) and ExecutionEventType enum
    - ExecutionListener: extends Listener, provides a default onEvent()
      that dispatches to the appropriate typed callback
    - RepositoryEvent: extends Event, exposes repository interactions
      (artifact/metadata resolution, deployment, installation) via noun-style
      accessors and RepositoryEventType enum
    - RepositoryListener: extends Listener, provides a default onEvent()
      that dispatches to the appropriate typed callback
    - RepositoryMetadata: @Immutable value type with noun-style accessors
    - Session.registerListener() scoped to the underlying Resolver session
    
    Implementation:
    - MavenRepositoryListener bridges Resolver callbacks to Maven API events
      without leaking resolver-api types into the SPI
    - DefaultRepositoryEvent wraps the Resolver event with lazy field init
    - AbstractSession registers MavenRepositoryListener and routes events
    - EventSpyImpl simplified to a single listener.onEvent(event) call
    - SessionStub returns empty immutable collection (non-null contract)
    
    Deleted types:
    - SessionEvent (redundant; Event is now the base)
    - TypedListener (each sub-interface provides its own default onEvent())
    
    Tests: MavenRepositoryListenerTest, EventSpyImplTest,
    DefaultRepositorySystemSessionFactoryTest cover event mapping,
    combined listener registration, derived-session behavior,
    concurrent updates, and failure isolation. Document combined listener
    dispatch pattern for listeners implementing both ExecutionListener
    and RepositoryListener.
    
    Co-authored-by: Guillaume Nodet <gn...@gmail.com>
---
 .../src/main/java/org/apache/maven/api/Event.java  |  66 +++-
 .../api/{Listener.java => ExecutionEvent.java}     |  34 +-
 .../api/{Listener.java => ExecutionEventType.java} |  29 +-
 .../org/apache/maven/api/ExecutionListener.java    | 192 ++++++++++++
 .../main/java/org/apache/maven/api/Listener.java   |  21 +-
 .../java/org/apache/maven/api/RepositoryEvent.java |  70 +++++
 .../{Listener.java => RepositoryEventType.java}    |  31 +-
 .../org/apache/maven/api/RepositoryListener.java   | 209 +++++++++++++
 .../org/apache/maven/api/RepositoryMetadata.java   |  93 ++++++
 .../main/java/org/apache/maven/api/Session.java    |   4 +-
 .../DefaultRepositorySystemSessionFactory.java     |   4 +-
 .../apache/maven/internal/impl/DefaultEvent.java   |  32 +-
 .../apache/maven/internal/impl/EventSpyImpl.java   |  22 +-
 .../DefaultRepositorySystemSessionFactoryTest.java |  41 +++
 .../maven/internal/impl/EventSpyImplTest.java      | 337 ++++++++++++++++++++
 .../org/apache/maven/impl/AbstractSession.java     |   9 +-
 .../apache/maven/impl/DefaultRepositoryEvent.java  | 161 ++++++++++
 .../apache/maven/impl/MavenRepositoryListener.java | 160 ++++++++++
 .../apache/maven/impl/standalone/ApiRunner.java    |   2 +
 .../maven/impl/MavenRepositoryListenerTest.java    | 347 +++++++++++++++++++++
 .../maven/impl/standalone/RequestTraceTest.java    |  32 +-
 .../maven/testing/plugin/stubs/SessionStub.java    |   2 +-
 22 files changed, 1823 insertions(+), 75 deletions(-)

diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Event.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Event.java
index 0323abd435..22f725e675 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Event.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Event.java
@@ -21,54 +21,100 @@
 import java.util.Optional;
 
 import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
 import org.apache.maven.api.annotations.Nonnull;
 
 /**
- * Event sent by maven during various phases of the build process.
- * Such events can be listened to using {@link Listener}s objects
- * registered in the {@link Session}.
+ * Base interface for all Maven events.
+ * Specific event families extend this interface to provide typed event data.
+ * Events can be listened to using {@link Listener} objects registered in the {@link Session}.
  *
+ * @see ExecutionEvent
+ * @see RepositoryEvent
+ * @see Listener
  * @since 4.0.0
  */
 @Experimental
+@Immutable
 public interface Event {
 
     /**
-     * Gets the type of the event.
+     * Returns the session from which this event originates.
      *
-     * @return the type of the event, never {@code null}
+     * @return the current session, never {@code null}
      */
     @Nonnull
-    EventType getType();
+    Session session();
 
     /**
      * Gets the session from which this event originates.
      *
      * @return the current session, never {@code null}
+     * @deprecated Use {@link #session()} instead.
+     */
+    @Deprecated(since = "4.1.0", forRemoval = true)
+    @Nonnull
+    default Session getSession() {
+        return session();
+    }
+
+    /**
+     * Gets the type of the event.
+     *
+     * @return the execution event type, never {@code null}; only meaningful when this event is an {@link ExecutionEvent}
+     * @throws UnsupportedOperationException if this event is not an {@link ExecutionEvent}
+     * @deprecated Use {@link ExecutionEvent#type()} instead.
      */
+    @Deprecated(since = "4.1.0", forRemoval = true)
     @Nonnull
-    Session getSession();
+    default EventType getType() {
+        if (this instanceof ExecutionEvent ee) {
+            return EventType.valueOf(ee.type().name());
+        }
+        throw new UnsupportedOperationException("getType() is only supported on ExecutionEvent instances");
+    }
 
     /**
      * Gets the current project (if any).
      *
      * @return the current project or {@code empty()} if not applicable
+     * @deprecated Use {@link ExecutionEvent#project()} instead.
      */
+    @Deprecated(since = "4.1.0", forRemoval = true)
     @Nonnull
-    Optional<Project> getProject();
+    default Optional<Project> getProject() {
+        if (this instanceof ExecutionEvent ee) {
+            return ee.project();
+        }
+        return Optional.empty();
+    }
 
     /**
      * Gets the current mojo execution (if any).
      *
      * @return the current mojo execution or {@code empty()} if not applicable
+     * @deprecated Use {@link ExecutionEvent#mojoExecution()} instead.
      */
+    @Deprecated(since = "4.1.0", forRemoval = true)
     @Nonnull
-    Optional<MojoExecution> getMojoExecution();
+    default Optional<MojoExecution> getMojoExecution() {
+        if (this instanceof ExecutionEvent ee) {
+            return ee.mojoExecution();
+        }
+        return Optional.empty();
+    }
 
     /**
      * Gets the exception that caused the event (if any).
      *
      * @return the exception or {@code empty()} if none
+     * @deprecated Use {@link ExecutionEvent#exception()} instead.
      */
-    Optional<Exception> getException();
+    @Deprecated(since = "4.1.0", forRemoval = true)
+    default Optional<Exception> getException() {
+        if (this instanceof ExecutionEvent ee) {
+            return ee.exception();
+        }
+        return Optional.empty();
+    }
 }
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEvent.java
similarity index 55%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEvent.java
index dda744f737..8e06b9c709 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEvent.java
@@ -18,19 +18,37 @@
  */
 package org.apache.maven.api;
 
-import org.apache.maven.api.annotations.Consumer;
+import java.util.Optional;
+
 import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
 import org.apache.maven.api.annotations.Nonnull;
 
 /**
- * A listener for session events.
- * TODO: open this to other events like similar to {@code org.apache.maven.eventspy.EventSpy}
+ * A build execution event with noun-style accessors.
+ * Extends {@link Event} so existing execution listeners can consume the same event.
  *
- * @since 4.0.0
+ * @see ExecutionListener
+ * @see ExecutionEventType
+ * @since 4.1.0
  */
 @Experimental
-@FunctionalInterface
-@Consumer
-public interface Listener {
-    void onEvent(@Nonnull Event event);
+@Immutable
+public interface ExecutionEvent extends Event {
+
+    /** {@return the kind of execution operation} */
+    @Nonnull
+    ExecutionEventType type();
+
+    /** {@return the current project, if applicable} */
+    @Nonnull
+    Optional<Project> project();
+
+    /** {@return the current mojo execution, if applicable} */
+    @Nonnull
+    Optional<MojoExecution> mojoExecution();
+
+    /** {@return the failure associated with this event, if any} */
+    @Nonnull
+    Optional<Exception> exception();
 }
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEventType.java
similarity index 66%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEventType.java
index dda744f737..b5727733f3 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionEventType.java
@@ -18,19 +18,30 @@
  */
 package org.apache.maven.api;
 
-import org.apache.maven.api.annotations.Consumer;
 import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Nonnull;
 
 /**
- * A listener for session events.
- * TODO: open this to other events like similar to {@code org.apache.maven.eventspy.EventSpy}
+ * The possible types of build execution events.
  *
- * @since 4.0.0
+ * @since 4.1.0
  */
 @Experimental
-@FunctionalInterface
-@Consumer
-public interface Listener {
-    void onEvent(@Nonnull Event event);
+public enum ExecutionEventType {
+    PROJECT_DISCOVERY_STARTED,
+    SESSION_STARTED,
+    SESSION_ENDED,
+    PROJECT_SKIPPED,
+    PROJECT_STARTED,
+    PROJECT_SUCCEEDED,
+    PROJECT_FAILED,
+    MOJO_SKIPPED,
+    MOJO_STARTED,
+    MOJO_SUCCEEDED,
+    MOJO_FAILED,
+    FORK_STARTED,
+    FORK_SUCCEEDED,
+    FORK_FAILED,
+    FORKED_PROJECT_STARTED,
+    FORKED_PROJECT_SUCCEEDED,
+    FORKED_PROJECT_FAILED,
 }
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionListener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionListener.java
new file mode 100644
index 0000000000..cd5797963d
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/ExecutionListener.java
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+
+/**
+ * Receives build execution events through typed callbacks.
+ * Each method corresponds to a specific {@link ExecutionEventType} and has a no-op default implementation,
+ * so implementations only need to override the methods they care about.
+ * <p>
+ * Register an {@code ExecutionListener} via {@link Session#registerListener(Listener)}.
+ * Implementations must support concurrent notification during parallel builds.
+ *
+ * @see ExecutionEvent
+ * @see ExecutionEventType
+ * @since 4.1.0
+ */
+@Experimental
+@Consumer
+public interface ExecutionListener extends Listener {
+
+    /**
+     * Dispatches the given event to the appropriate typed callback.
+     * This default implementation routes {@link ExecutionEvent}s to the matching method
+     * and ignores other event types.
+     *
+     * @param event the event to dispatch
+     */
+    @Override
+    default void onEvent(@Nonnull Event event) {
+        if (event instanceof ExecutionEvent ee) {
+            switch (ee.type()) {
+                case PROJECT_DISCOVERY_STARTED -> projectDiscoveryStarted(ee);
+                case SESSION_STARTED -> sessionStarted(ee);
+                case SESSION_ENDED -> sessionEnded(ee);
+                case PROJECT_SKIPPED -> projectSkipped(ee);
+                case PROJECT_STARTED -> projectStarted(ee);
+                case PROJECT_SUCCEEDED -> projectSucceeded(ee);
+                case PROJECT_FAILED -> projectFailed(ee);
+                case MOJO_SKIPPED -> mojoSkipped(ee);
+                case MOJO_STARTED -> mojoStarted(ee);
+                case MOJO_SUCCEEDED -> mojoSucceeded(ee);
+                case MOJO_FAILED -> mojoFailed(ee);
+                case FORK_STARTED -> forkStarted(ee);
+                case FORK_SUCCEEDED -> forkSucceeded(ee);
+                case FORK_FAILED -> forkFailed(ee);
+                case FORKED_PROJECT_STARTED -> forkedProjectStarted(ee);
+                case FORKED_PROJECT_SUCCEEDED -> forkedProjectSucceeded(ee);
+                case FORKED_PROJECT_FAILED -> forkedProjectFailed(ee);
+                default -> {}
+            }
+        }
+    }
+
+    /**
+     * Called when project discovery has started.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void projectDiscoveryStarted(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when the build session starts.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void sessionStarted(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when the build session ends.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void sessionEnded(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a project is skipped.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void projectSkipped(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a project starts.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void projectStarted(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a project completes successfully.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void projectSucceeded(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a project fails.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void projectFailed(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a mojo is skipped.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void mojoSkipped(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a mojo starts.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void mojoStarted(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a mojo completes successfully.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void mojoSucceeded(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a mojo fails.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void mojoFailed(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a forked execution starts.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void forkStarted(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a forked execution completes successfully.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void forkSucceeded(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a forked execution fails.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void forkFailed(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a forked project starts.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void forkedProjectStarted(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a forked project completes successfully.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void forkedProjectSucceeded(@Nonnull ExecutionEvent event) {}
+
+    /**
+     * Called when a forked project fails.
+     *
+     * @param event the execution details, including the session and any project, mojo, or failure
+     */
+    default void forkedProjectFailed(@Nonnull ExecutionEvent event) {}
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
index dda744f737..ab11b458d5 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
@@ -24,7 +24,21 @@
 
 /**
  * A listener for session events.
- * TODO: open this to other events like similar to {@code org.apache.maven.eventspy.EventSpy}
+ * Existing implementations and lambdas receive execution events through {@link #onEvent(Event)}.
+ * Implement {@link ExecutionListener} or {@link RepositoryListener} for typed callbacks.
+ * <p>
+ * A listener implementing both interfaces must override {@link #onEvent(Event)} to resolve
+ * their conflicting default methods. Delegate to both defaults to receive typed callbacks
+ * for both event families; each default ignores events from the other family:
+ * <pre>{@code
+ * class CombinedListener implements ExecutionListener, RepositoryListener {
+ *     @Override
+ *     public void onEvent(Event event) {
+ *         ExecutionListener.super.onEvent(event);
+ *         RepositoryListener.super.onEvent(event);
+ *     }
+ * }
+ * }</pre>
  *
  * @since 4.0.0
  */
@@ -32,5 +46,10 @@
 @FunctionalInterface
 @Consumer
 public interface Listener {
+    /**
+     * Receives a build execution event.
+     *
+     * @param event the execution event
+     */
     void onEvent(@Nonnull Event event);
 }
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEvent.java b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEvent.java
new file mode 100644
index 0000000000..271f9bf5b4
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEvent.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.services.RequestTrace;
+
+/**
+ * Describes an artifact or metadata operation performed against a repository.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+@Immutable
+public interface RepositoryEvent extends Event {
+
+    /** {@return the kind of repository operation represented by this event} */
+    @Nonnull
+    RepositoryEventType type();
+
+    /** {@return the artifact involved in the event, if any} */
+    @Nonnull
+    Optional<Artifact> artifact();
+
+    /** {@return the metadata involved in the event, if any} */
+    @Nonnull
+    Optional<RepositoryMetadata> metadata();
+
+    /** {@return the local path involved in the event, if any} */
+    @Nonnull
+    Optional<Path> path();
+
+    /** {@return the repository involved in the event, if any} */
+    @Nonnull
+    Optional<Repository> repository();
+
+    /** {@return the primary failure associated with the event, if any} */
+    @Nonnull
+    Optional<Exception> exception();
+
+    /** {@return all failures associated with the event} */
+    @Nonnull
+    List<Exception> exceptions();
+
+    /** {@return the request trace associated with the event, if any} */
+    @Nonnull
+    Optional<RequestTrace> trace();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEventType.java
similarity index 61%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEventType.java
index dda744f737..e271f9751a 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Listener.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryEventType.java
@@ -18,19 +18,32 @@
  */
 package org.apache.maven.api;
 
-import org.apache.maven.api.annotations.Consumer;
 import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Nonnull;
 
 /**
- * A listener for session events.
- * TODO: open this to other events like similar to {@code org.apache.maven.eventspy.EventSpy}
+ * The possible types of repository events.
  *
- * @since 4.0.0
+ * @since 4.1.0
  */
 @Experimental
-@FunctionalInterface
-@Consumer
-public interface Listener {
-    void onEvent(@Nonnull Event event);
+public enum RepositoryEventType {
+    ARTIFACT_DESCRIPTOR_INVALID,
+    ARTIFACT_DESCRIPTOR_MISSING,
+    METADATA_INVALID,
+    ARTIFACT_RESOLVING,
+    ARTIFACT_RESOLVED,
+    METADATA_RESOLVING,
+    METADATA_RESOLVED,
+    ARTIFACT_DOWNLOADING,
+    ARTIFACT_DOWNLOADED,
+    METADATA_DOWNLOADING,
+    METADATA_DOWNLOADED,
+    ARTIFACT_INSTALLING,
+    ARTIFACT_INSTALLED,
+    METADATA_INSTALLING,
+    METADATA_INSTALLED,
+    ARTIFACT_DEPLOYING,
+    ARTIFACT_DEPLOYED,
+    METADATA_DEPLOYING,
+    METADATA_DEPLOYED,
 }
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryListener.java b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryListener.java
new file mode 100644
index 0000000000..920fb301ad
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryListener.java
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+
+/**
+ * Receives repository events emitted while resolving, installing, and deploying artifacts and metadata.
+ * Each method corresponds to a specific {@link RepositoryEventType} and has a no-op default implementation,
+ * so implementations only need to override the methods they care about.
+ * <p>
+ * Register a {@code RepositoryListener} via {@link Session#registerListener(Listener)}.
+ * Implementations must be thread-safe because callbacks may occur concurrently.
+ * Runtime exceptions thrown by a listener do not stop repository processing or notification of other listeners.
+ *
+ * @see RepositoryEvent
+ * @see RepositoryEventType
+ * @since 4.1.0
+ */
+@Experimental
+@Consumer
+public interface RepositoryListener extends Listener {
+
+    /**
+     * Dispatches the given event to the appropriate typed callback.
+     * This default implementation routes {@link RepositoryEvent}s to the matching method
+     * and ignores other event types.
+     *
+     * @param event the event to dispatch
+     */
+    @Override
+    default void onEvent(@Nonnull Event event) {
+        if (event instanceof RepositoryEvent re) {
+            switch (re.type()) {
+                case ARTIFACT_DESCRIPTOR_INVALID -> artifactDescriptorInvalid(re);
+                case ARTIFACT_DESCRIPTOR_MISSING -> artifactDescriptorMissing(re);
+                case METADATA_INVALID -> metadataInvalid(re);
+                case ARTIFACT_RESOLVING -> artifactResolving(re);
+                case ARTIFACT_RESOLVED -> artifactResolved(re);
+                case METADATA_RESOLVING -> metadataResolving(re);
+                case METADATA_RESOLVED -> metadataResolved(re);
+                case ARTIFACT_DOWNLOADING -> artifactDownloading(re);
+                case ARTIFACT_DOWNLOADED -> artifactDownloaded(re);
+                case METADATA_DOWNLOADING -> metadataDownloading(re);
+                case METADATA_DOWNLOADED -> metadataDownloaded(re);
+                case ARTIFACT_INSTALLING -> artifactInstalling(re);
+                case ARTIFACT_INSTALLED -> artifactInstalled(re);
+                case METADATA_INSTALLING -> metadataInstalling(re);
+                case METADATA_INSTALLED -> metadataInstalled(re);
+                case ARTIFACT_DEPLOYING -> artifactDeploying(re);
+                case ARTIFACT_DEPLOYED -> artifactDeployed(re);
+                case METADATA_DEPLOYING -> metadataDeploying(re);
+                case METADATA_DEPLOYED -> metadataDeployed(re);
+                default -> {}
+            }
+        }
+    }
+
+    /**
+     * Called when an artifact descriptor could not be parsed.
+     *
+     * @param event the event containing the artifact and parse failure
+     */
+    default void artifactDescriptorInvalid(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called when an artifact descriptor could not be found.
+     *
+     * @param event the event containing the artifact whose descriptor is missing
+     */
+    default void artifactDescriptorMissing(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called when repository metadata could not be parsed.
+     *
+     * @param event the event containing the metadata and parse failure
+     */
+    default void metadataInvalid(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called before an artifact is resolved.
+     *
+     * @param event the event containing the artifact and, when applicable, its repository
+     */
+    default void artifactResolving(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called after an artifact resolution attempt finishes.
+     *
+     * @param event the event containing the artifact and the resulting path or failure
+     */
+    default void artifactResolved(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called before repository metadata is resolved.
+     *
+     * @param event the event containing the metadata and, when applicable, its repository
+     */
+    default void metadataResolving(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called after a repository metadata resolution attempt finishes.
+     *
+     * @param event the event containing the metadata and the resulting path or failure
+     */
+    default void metadataResolved(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called before an artifact is downloaded from a remote repository.
+     *
+     * @param event the event containing the artifact and remote repository
+     */
+    default void artifactDownloading(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called after an artifact download attempt finishes.
+     *
+     * @param event the event containing the artifact, remote repository, and resulting path or failure
+     */
+    default void artifactDownloaded(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called before repository metadata is downloaded from a remote repository.
+     *
+     * @param event the event containing the metadata and remote repository
+     */
+    default void metadataDownloading(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called after a repository metadata download attempt finishes.
+     *
+     * @param event the event containing the metadata, remote repository, and resulting path or failure
+     */
+    default void metadataDownloaded(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called before an artifact is installed into the local repository.
+     *
+     * @param event the event containing the artifact and source path
+     */
+    default void artifactInstalling(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called after an artifact installation attempt finishes.
+     *
+     * @param event the event containing the artifact, local repository path, and any failure
+     */
+    default void artifactInstalled(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called before repository metadata is installed into the local repository.
+     *
+     * @param event the event containing the metadata and source path
+     */
+    default void metadataInstalling(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called after a repository metadata installation attempt finishes.
+     *
+     * @param event the event containing the metadata, local repository path, and any failure
+     */
+    default void metadataInstalled(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called before an artifact is deployed to a remote repository.
+     *
+     * @param event the event containing the artifact, remote repository, and source path
+     */
+    default void artifactDeploying(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called after an artifact deployment attempt finishes.
+     *
+     * @param event the event containing the artifact, remote repository, and any failure
+     */
+    default void artifactDeployed(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called before repository metadata is deployed to a remote repository.
+     *
+     * @param event the event containing the metadata, remote repository, and source path
+     */
+    default void metadataDeploying(@Nonnull RepositoryEvent event) {}
+
+    /**
+     * Called after a repository metadata deployment attempt finishes.
+     *
+     * @param event the event containing the metadata, remote repository, and any failure
+     */
+    default void metadataDeployed(@Nonnull RepositoryEvent event) {}
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryMetadata.java b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryMetadata.java
new file mode 100644
index 0000000000..d5be0ed745
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/RepositoryMetadata.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.api;
+
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+
+/**
+ * Metadata involved in a repository event.
+ *
+ * @since 4.1.0
+ */
+@Experimental
+@Immutable
+public interface RepositoryMetadata {
+
+    /**
+     * Describes which artifact versions a metadata item applies to.
+     */
+    enum Nature {
+        /** Metadata that applies only to release versions. */
+        RELEASE,
+
+        /** Metadata that applies only to snapshot versions. */
+        SNAPSHOT,
+
+        /** Metadata that applies to both release and snapshot versions. */
+        RELEASE_OR_SNAPSHOT,
+    }
+
+    /**
+     * {@return the group identifier, or an empty string if the metadata applies to the entire repository}
+     */
+    @Nonnull
+    String groupId();
+
+    /**
+     * {@return the artifact identifier, or an empty string if the metadata applies at group level}
+     */
+    @Nonnull
+    String artifactId();
+
+    /**
+     * {@return the version, or an empty string if the metadata applies at artifact level}
+     */
+    @Nonnull
+    String version();
+
+    /**
+     * {@return the metadata filename, such as {@code maven-metadata.xml}}
+     */
+    @Nonnull
+    String type();
+
+    /**
+     * {@return the artifact version nature to which the metadata applies}
+     */
+    @Nonnull
+    Nature nature();
+
+    /**
+     * {@return the local path of the metadata file if it has been resolved}
+     */
+    @Nonnull
+    Optional<Path> path();
+
+    /**
+     * {@return the read-only properties associated with the metadata}
+     */
+    @Nonnull
+    Map<String, String> properties();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java
index 38e608021c..0138f461b1 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Session.java
@@ -192,7 +192,9 @@ default Map<String, String> getEffectiveProperties() {
     Session withContext(@Nonnull RequestTrace trace);
 
     /**
-     * Register the given listener which will receive all events.
+     * Registers a listener for execution events, repository events, or both.
+     * Typed listeners receive only their event-specific callbacks. Legacy listeners receive execution events.
+     * Registration is shared by Maven sessions using the same underlying repository system session.
      *
      * @param listener the listener to register
      * @throws NullPointerException if {@code listener} is null
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
index b3bd5d95a5..45ca17114b 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactory.java
@@ -40,6 +40,7 @@
 import org.apache.maven.artifact.repository.ArtifactRepository;
 import org.apache.maven.eventspy.internal.EventSpyDispatcher;
 import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.impl.MavenRepositoryListener;
 import org.apache.maven.impl.resolver.MavenSessionBuilderSupplier;
 import org.apache.maven.impl.resolver.type.TypeRegistryAdapter;
 import org.apache.maven.internal.xml.XmlPlexusConfiguration;
@@ -380,7 +381,8 @@ public SessionBuilder newRepositorySessionBuilder(MavenExecutionRequest request)
 
         sessionBuilder.setTransferListener(request.getTransferListener());
 
-        RepositoryListener repositoryListener = eventSpyDispatcher.chainListener(new LoggingRepositoryListener(logger));
+        RepositoryListener repositoryListener = eventSpyDispatcher.chainListener(
+                new ChainedRepositoryListener(new LoggingRepositoryListener(logger), new MavenRepositoryListener()));
 
         boolean recordReverseTree = Boolean.parseBoolean(
                 mergedProps.getOrDefault(Constants.MAVEN_REPO_LOCAL_RECORD_REVERSE_TREE, Boolean.FALSE.toString()));
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultEvent.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultEvent.java
index 7003824de6..abffffc64d 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultEvent.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultEvent.java
@@ -20,46 +20,52 @@
 
 import java.util.Optional;
 
-import org.apache.maven.api.Event;
 import org.apache.maven.api.EventType;
+import org.apache.maven.api.ExecutionEventType;
 import org.apache.maven.api.MojoExecution;
 import org.apache.maven.api.Project;
 import org.apache.maven.api.Session;
 import org.apache.maven.execution.ExecutionEvent;
 
-public class DefaultEvent implements Event {
+public class DefaultEvent implements org.apache.maven.api.ExecutionEvent {
     private final InternalMavenSession session;
-    private final ExecutionEvent delegate;
+    private final Project project;
+    private final MojoExecution mojoExecution;
+    private final Exception exception;
     private final EventType eventType;
 
     public DefaultEvent(InternalMavenSession session, ExecutionEvent delegate, EventType eventType) {
         this.session = session;
-        this.delegate = delegate;
+        this.project = session.getProject(delegate.getProject());
+        this.mojoExecution = delegate.getMojoExecution() != null
+                ? new DefaultMojoExecution(session, delegate.getMojoExecution())
+                : null;
+        this.exception = delegate.getException();
         this.eventType = eventType;
     }
 
     @Override
-    public EventType getType() {
-        return eventType;
+    public ExecutionEventType type() {
+        return ExecutionEventType.valueOf(eventType.name());
     }
 
     @Override
-    public Session getSession() {
+    public Session session() {
         return session;
     }
 
     @Override
-    public Optional<Project> getProject() {
-        return Optional.ofNullable(session.getProject(delegate.getProject()));
+    public Optional<Project> project() {
+        return Optional.ofNullable(project);
     }
 
     @Override
-    public Optional<MojoExecution> getMojoExecution() {
-        return Optional.ofNullable(delegate.getMojoExecution()).map(me -> new DefaultMojoExecution(session, me));
+    public Optional<MojoExecution> mojoExecution() {
+        return Optional.ofNullable(mojoExecution);
     }
 
     @Override
-    public Optional<Exception> getException() {
-        return Optional.ofNullable(delegate.getException());
+    public Optional<Exception> exception() {
+        return Optional.ofNullable(exception);
     }
 }
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/EventSpyImpl.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/EventSpyImpl.java
index 757b08a7ee..382a4ec100 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/EventSpyImpl.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/EventSpyImpl.java
@@ -23,18 +23,24 @@
 
 import java.util.Collection;
 
-import org.apache.maven.api.Event;
 import org.apache.maven.api.EventType;
 import org.apache.maven.api.Listener;
 import org.apache.maven.eventspy.EventSpy;
 import org.apache.maven.execution.ExecutionEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Bridges between Maven3 events and Maven4 events.
+ * Each listener's {@link Listener#onEvent(org.apache.maven.api.Event)} method handles its own dispatch:
+ * legacy listeners receive the event directly, while typed listeners (such as
+ * {@link org.apache.maven.api.ExecutionListener}) route it to their specific callbacks.
  */
 @Named
 @Singleton
 public class EventSpyImpl implements EventSpy {
+    private static final Logger LOGGER = LoggerFactory.getLogger(EventSpyImpl.class);
+
     @Override
     public void init(Context context) throws Exception {}
 
@@ -46,16 +52,24 @@ public void onEvent(Object arg) throws Exception {
             EventType eventType = convert(ee.getType());
             Collection<Listener> listeners = session.getListeners();
             if (!listeners.isEmpty()) {
-                Event event = new DefaultEvent(session, ee, eventType);
+                org.apache.maven.api.ExecutionEvent event = new DefaultEvent(session, ee, eventType);
                 for (Listener listener : listeners) {
-                    listener.onEvent(event);
+                    try {
+                        listener.onEvent(event);
+                    } catch (RuntimeException e) {
+                        LOGGER.warn(
+                                "Failed to notify listener {} about {}",
+                                listener.getClass().getName(),
+                                eventType,
+                                e);
+                    }
                 }
             }
         }
     }
 
     /**
-     * Simple "conversion" from Maven3 event type enum to Maven4 enum.
+     * Converts the Maven 3 execution event type to its Maven API counterpart.
      */
     protected EventType convert(ExecutionEvent.Type type) {
         return EventType.values()[type.ordinal()];
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java
index 111fb3548b..c6d30a13e5 100644
--- a/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/aether/DefaultRepositorySystemSessionFactoryTest.java
@@ -26,13 +26,18 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Properties;
+import java.util.concurrent.atomic.AtomicReference;
 
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryEventType;
+import org.apache.maven.api.RepositoryListener;
 import org.apache.maven.artifact.InvalidRepositoryException;
 import org.apache.maven.artifact.repository.ArtifactRepository;
 import org.apache.maven.bridge.MavenRepositorySystem;
 import org.apache.maven.eventspy.internal.EventSpyDispatcher;
 import org.apache.maven.execution.DefaultMavenExecutionRequest;
 import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.impl.InternalSession;
 import org.apache.maven.internal.impl.DefaultTypeRegistry;
 import org.apache.maven.rtinfo.RuntimeInformation;
 import org.apache.maven.settings.Server;
@@ -50,6 +55,8 @@
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrowsExactly;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 /**
  * UT for {@link DefaultRepositorySystemSessionFactory}.
@@ -77,6 +84,40 @@ public class DefaultRepositorySystemSessionFactoryTest {
     @Inject
     protected VersionFilterBuilder versionFilterBuilder;
 
+    @Test
+    void exposesRepositoryEventsThroughMavenApi() throws InvalidRepositoryException {
+        DefaultRepositorySystemSessionFactory systemSessionFactory = new DefaultRepositorySystemSessionFactory(
+                aetherRepositorySystem,
+                eventSpyDispatcher,
+                information,
+                defaultTypeRegistry,
+                versionScheme,
+                Collections.emptyMap(),
+                versionFilterBuilder);
+        MavenExecutionRequest request = new DefaultMavenExecutionRequest();
+        request.setLocalRepository(getLocalRepository());
+        org.eclipse.aether.RepositorySystemSession resolverSession = systemSessionFactory.newRepositorySession(request);
+        InternalSession session = mock(InternalSession.class);
+        AtomicReference<RepositoryEvent> received = new AtomicReference<>();
+        RepositoryListener listener = new RepositoryListener() {
+            @Override
+            public void artifactResolved(RepositoryEvent event) {
+                received.set(event);
+            }
+        };
+        when(session.getListeners()).thenReturn(List.of(listener));
+        InternalSession.associate(resolverSession, session);
+
+        resolverSession
+                .getRepositoryListener()
+                .artifactResolved(new org.eclipse.aether.RepositoryEvent.Builder(
+                                resolverSession, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVED)
+                        .build());
+
+        assertNotNull(received.get());
+        assertEquals(RepositoryEventType.ARTIFACT_RESOLVED, received.get().type());
+    }
+
     @Test
     void isNoSnapshotUpdatesTest() throws InvalidRepositoryException {
         DefaultRepositorySystemSessionFactory systemSessionFactory = new DefaultRepositorySystemSessionFactory(
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/EventSpyImplTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/EventSpyImplTest.java
new file mode 100644
index 0000000000..0ba976c851
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/EventSpyImplTest.java
@@ -0,0 +1,337 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.internal.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.maven.api.Event;
+import org.apache.maven.api.EventType;
+import org.apache.maven.api.ExecutionEvent;
+import org.apache.maven.api.ExecutionEventType;
+import org.apache.maven.api.ExecutionListener;
+import org.apache.maven.api.Listener;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryListener;
+import org.apache.maven.api.Session;
+import org.apache.maven.execution.DefaultMavenExecutionRequest;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.impl.InternalSession;
+import org.apache.maven.impl.MavenRepositoryListener;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.RepositorySystem;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class EventSpyImplTest {
+    @Test
+    void dispatchesAllExecutionCallbacksAndPreservesLegacyEvents() throws Exception {
+        TestContext context = new TestContext();
+        RecordingListener typed = new RecordingListener();
+        List<Event> legacy = new ArrayList<>();
+        context.session.registerListener(typed);
+        context.session.registerListener(legacy::add);
+
+        for (org.apache.maven.execution.ExecutionEvent.Type type :
+                org.apache.maven.execution.ExecutionEvent.Type.values()) {
+            context.execution(type);
+        }
+
+        assertEquals(List.of(ExecutionEventType.values()), typed.executions);
+        assertEquals(
+                List.of(EventType.values()), legacy.stream().map(Event::getType).toList());
+        for (Event event : legacy) {
+            ExecutionEvent typedEvent = (ExecutionEvent) event;
+            assertSame(context.session, event.getSession());
+            assertSame(event.getSession(), typedEvent.session());
+            assertEquals(event.getProject(), typedEvent.project());
+            assertEquals(event.getMojoExecution(), typedEvent.mojoExecution());
+            assertEquals(event.getException(), typedEvent.exception());
+        }
+    }
+
+    @Test
+    void sharesOneRegistrationForBothEventFamiliesAcrossDerivedSessions() throws Exception {
+        TestContext context = new TestContext();
+        RecordingListener combined = new RecordingListener();
+        AtomicInteger legacyCalls = new AtomicInteger();
+        Listener legacy = event -> legacyCalls.incrementAndGet();
+        AtomicInteger repositoryOnlyCalls = new AtomicInteger();
+        RepositoryListener repositoryOnly = new RepositoryListener() {
+            @Override
+            public void artifactResolving(RepositoryEvent event) {
+                repositoryOnlyCalls.incrementAndGet();
+            }
+        };
+        context.session.registerListener(combined);
+        context.session.registerListener(legacy);
+        context.session.registerListener(repositoryOnly);
+        Session derived = context.session.withRemoteRepositories(List.of());
+        assertIterableEquals(context.session.getListeners(), derived.getListeners());
+        assertThrows(
+                UnsupportedOperationException.class,
+                () -> derived.getListeners().clear());
+
+        context.execution(org.apache.maven.execution.ExecutionEvent.Type.SessionStarted);
+        context.repository();
+        assertEquals(List.of(ExecutionEventType.SESSION_STARTED), combined.executions);
+        assertEquals(1, combined.repositoryCalls);
+        assertEquals(1, repositoryOnlyCalls.get());
+        assertEquals(1, legacyCalls.get());
+
+        derived.unregisterListener(combined);
+        derived.unregisterListener(legacy);
+        derived.unregisterListener(repositoryOnly);
+        context.execution(org.apache.maven.execution.ExecutionEvent.Type.SessionStarted);
+        context.repository();
+        assertTrue(context.session.getListeners().isEmpty());
+        assertEquals(1, combined.repositoryCalls);
+        assertEquals(1, legacyCalls.get());
+    }
+
+    @Test
+    void isolatesExecutionListenerFailures() throws Exception {
+        TestContext context = new TestContext();
+        context.session.registerListener(new ExecutionListener() {
+            @Override
+            public void sessionStarted(ExecutionEvent event) {
+                throw new IllegalStateException("listener failure");
+            }
+        });
+        AtomicInteger calls = new AtomicInteger();
+        context.session.registerListener(event -> calls.incrementAndGet());
+        context.execution(org.apache.maven.execution.ExecutionEvent.Type.SessionStarted);
+        assertEquals(1, calls.get());
+    }
+
+    @Test
+    void rejectsNullRegistrationAndRemoval() {
+        TestContext context = new TestContext();
+        assertThrows(NullPointerException.class, () -> context.session.registerListener(null));
+        assertThrows(NullPointerException.class, () -> context.session.unregisterListener(null));
+    }
+
+    @Test
+    void supportsCombinedListenerWithoutLegacyOverride() throws Exception {
+        TestContext context = new TestContext();
+        class CombinedListener implements ExecutionListener, RepositoryListener {
+            int executionCalls;
+            int repositoryCalls;
+
+            @Override
+            public void onEvent(Event event) {
+                ExecutionListener.super.onEvent(event);
+                RepositoryListener.super.onEvent(event);
+            }
+
+            @Override
+            public void sessionStarted(ExecutionEvent event) {
+                executionCalls++;
+            }
+
+            @Override
+            public void artifactResolving(RepositoryEvent event) {
+                repositoryCalls++;
+            }
+        }
+        CombinedListener listener = new CombinedListener();
+        context.session.registerListener(listener);
+        context.execution(org.apache.maven.execution.ExecutionEvent.Type.SessionStarted);
+        context.repository();
+        assertEquals(1, listener.executionCalls);
+        assertEquals(1, listener.repositoryCalls);
+    }
+
+    @Test
+    void supportsConcurrentRegistrationAndRemovalThroughDerivedSession() {
+        TestContext context = new TestContext();
+        Session derived = context.session.withRemoteRepositories(List.of());
+        List<Listener> listeners = java.util.stream.IntStream.range(0, 100)
+                .mapToObj(i -> (Listener) new ExecutionListener() {})
+                .toList();
+        listeners.parallelStream().forEach(context.session::registerListener);
+        assertEquals(100, derived.getListeners().size());
+        listeners.parallelStream().forEach(derived::unregisterListener);
+        assertTrue(context.session.getListeners().isEmpty());
+    }
+
+    @Test
+    void capturesExecutionDetailsWhenEventIsCreated() {
+        TestContext context = new TestContext();
+        org.apache.maven.execution.ExecutionEvent source = mock(org.apache.maven.execution.ExecutionEvent.class);
+        Exception failure = new Exception("original failure");
+        when(source.getException()).thenReturn(failure);
+        ExecutionEvent event = new DefaultEvent(context.session, source, EventType.PROJECT_FAILED);
+        when(source.getException()).thenReturn(new Exception("changed failure"));
+        assertSame(failure, event.exception().orElseThrow());
+        assertSame(failure, event.getException().orElseThrow());
+    }
+
+    private static class TestContext {
+        final DefaultRepositorySystemSession resolver = new DefaultRepositorySystemSession(h -> false);
+        final MavenSession maven = new MavenSession(null, resolver, new DefaultMavenExecutionRequest(), null);
+        final DefaultSession session =
+                new DefaultSession(maven, mock(RepositorySystem.class), List.of(), null, null, null);
+
+        TestContext() {
+            maven.setSession(session);
+            resolver.getData().set(InternalSession.class, session);
+        }
+
+        void execution(org.apache.maven.execution.ExecutionEvent.Type type) throws Exception {
+            org.apache.maven.execution.ExecutionEvent event = mock(org.apache.maven.execution.ExecutionEvent.class);
+            when(event.getSession()).thenReturn(maven);
+            when(event.getType()).thenReturn(type);
+            new EventSpyImpl().onEvent(event);
+        }
+
+        void repository() {
+            new MavenRepositoryListener()
+                    .artifactResolving(new org.eclipse.aether.RepositoryEvent.Builder(
+                                    resolver, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVING)
+                            .build());
+        }
+    }
+
+    private static class RecordingListener implements ExecutionListener, RepositoryListener {
+        final List<ExecutionEventType> executions = new ArrayList<>();
+        int repositoryCalls;
+
+        @Override
+        public void onEvent(Event event) {
+            ExecutionListener.super.onEvent(event);
+            RepositoryListener.super.onEvent(event);
+        }
+
+        @Override
+        public void artifactResolving(RepositoryEvent event) {
+            repositoryCalls++;
+        }
+
+        @Override
+        public void projectDiscoveryStarted(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.PROJECT_DISCOVERY_STARTED, event.type());
+            executions.add(ExecutionEventType.PROJECT_DISCOVERY_STARTED);
+        }
+
+        @Override
+        public void sessionStarted(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.SESSION_STARTED, event.type());
+            executions.add(ExecutionEventType.SESSION_STARTED);
+        }
+
+        @Override
+        public void sessionEnded(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.SESSION_ENDED, event.type());
+            executions.add(ExecutionEventType.SESSION_ENDED);
+        }
+
+        @Override
+        public void projectSkipped(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.PROJECT_SKIPPED, event.type());
+            executions.add(ExecutionEventType.PROJECT_SKIPPED);
+        }
+
+        @Override
+        public void projectStarted(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.PROJECT_STARTED, event.type());
+            executions.add(ExecutionEventType.PROJECT_STARTED);
+        }
+
+        @Override
+        public void projectSucceeded(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.PROJECT_SUCCEEDED, event.type());
+            executions.add(ExecutionEventType.PROJECT_SUCCEEDED);
+        }
+
+        @Override
+        public void projectFailed(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.PROJECT_FAILED, event.type());
+            executions.add(ExecutionEventType.PROJECT_FAILED);
+        }
+
+        @Override
+        public void mojoSkipped(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.MOJO_SKIPPED, event.type());
+            executions.add(ExecutionEventType.MOJO_SKIPPED);
+        }
+
+        @Override
+        public void mojoStarted(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.MOJO_STARTED, event.type());
+            executions.add(ExecutionEventType.MOJO_STARTED);
+        }
+
+        @Override
+        public void mojoSucceeded(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.MOJO_SUCCEEDED, event.type());
+            executions.add(ExecutionEventType.MOJO_SUCCEEDED);
+        }
+
+        @Override
+        public void mojoFailed(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.MOJO_FAILED, event.type());
+            executions.add(ExecutionEventType.MOJO_FAILED);
+        }
+
+        @Override
+        public void forkStarted(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.FORK_STARTED, event.type());
+            executions.add(ExecutionEventType.FORK_STARTED);
+        }
+
+        @Override
+        public void forkSucceeded(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.FORK_SUCCEEDED, event.type());
+            executions.add(ExecutionEventType.FORK_SUCCEEDED);
+        }
+
+        @Override
+        public void forkFailed(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.FORK_FAILED, event.type());
+            executions.add(ExecutionEventType.FORK_FAILED);
+        }
+
+        @Override
+        public void forkedProjectStarted(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.FORKED_PROJECT_STARTED, event.type());
+            executions.add(ExecutionEventType.FORKED_PROJECT_STARTED);
+        }
+
+        @Override
+        public void forkedProjectSucceeded(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.FORKED_PROJECT_SUCCEEDED, event.type());
+            executions.add(ExecutionEventType.FORKED_PROJECT_SUCCEEDED);
+        }
+
+        @Override
+        public void forkedProjectFailed(ExecutionEvent event) {
+            assertEquals(ExecutionEventType.FORKED_PROJECT_FAILED, event.type());
+            executions.add(ExecutionEventType.FORKED_PROJECT_FAILED);
+        }
+    }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/AbstractSession.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/AbstractSession.java
index 512174a465..bdce42779a 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/AbstractSession.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/AbstractSession.java
@@ -122,7 +122,7 @@ public abstract class AbstractSession implements InternalSession {
     protected final Lookup lookup;
     protected final Injector injector;
     private final Map<Class<? extends Service>, Service> services = new ConcurrentHashMap<>();
-    private final List<Listener> listeners = new CopyOnWriteArrayList<>();
+    private final List<Listener> listeners;
     private final Cache<org.eclipse.aether.graph.DependencyNode, Node> allNodes =
             Cache.newCache(Cache.ReferenceType.WEAK, "AbstractSession-Nodes");
     private final Map<Class<? extends Artifact>, Cache<org.eclipse.aether.artifact.Artifact, Artifact>> allArtifacts =
@@ -159,9 +159,16 @@ protected AbstractSession(
         this.repositories = getRepositories(repositories, resolverRepositories);
         this.lookup = lookup;
         this.injector = lookup != null ? lookup.lookupOptional(Injector.class).orElse(null) : null;
+        this.listeners = getListeners(session);
         this.context = context;
     }
 
+    @SuppressWarnings("unchecked")
+    private static List<Listener> getListeners(RepositorySystemSession session) {
+        // Both event families share the registration scope of the underlying repository system session.
+        return (List<Listener>) session.getData().computeIfAbsent(Listener.class, CopyOnWriteArrayList::new);
+    }
+
     @SuppressWarnings("unchecked")
     private static Stream<Class<? extends Service>> collectServiceInterfaces(Class<?> clazz) {
         if (clazz == null) {
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultRepositoryEvent.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultRepositoryEvent.java
new file mode 100644
index 0000000000..325906a852
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultRepositoryEvent.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.impl;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.maven.api.Artifact;
+import org.apache.maven.api.Repository;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryEventType;
+import org.apache.maven.api.RepositoryMetadata;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.services.RequestTrace;
+
+final class DefaultRepositoryEvent implements RepositoryEvent {
+
+    private final RepositoryEventType type;
+    private final Session session;
+    private final Artifact artifact;
+    private final RepositoryMetadata metadata;
+    private final Path path;
+    private final Repository repository;
+    private final Exception exception;
+    private final List<Exception> exceptions;
+    private final RequestTrace trace;
+
+    DefaultRepositoryEvent(InternalSession session, org.eclipse.aether.RepositoryEvent event) {
+        this.type = RepositoryEventType.valueOf(event.getType().name());
+        this.session = session;
+        this.artifact = event.getArtifact() != null ? session.getArtifact(event.getArtifact()) : null;
+        this.metadata = event.getMetadata() != null ? new DefaultRepositoryMetadata(event.getMetadata()) : null;
+        this.path = event.getPath();
+        this.repository = event.getRepository() != null
+                ? session.getRepository(event.getRepository()).orElse(null)
+                : null;
+        this.exception = event.getException();
+        this.exceptions = event.getExceptions() != null ? List.copyOf(event.getExceptions()) : List.of();
+        RequestTrace currentTrace = session.getCurrentTrace();
+        this.trace = RequestTraceHelper.toMaven(currentTrace != null ? currentTrace.context() : null, event.getTrace());
+    }
+
+    @Override
+    public RepositoryEventType type() {
+        return type;
+    }
+
+    @Override
+    public Session session() {
+        return session;
+    }
+
+    @Override
+    public Optional<Artifact> artifact() {
+        return Optional.ofNullable(artifact);
+    }
+
+    @Override
+    public Optional<RepositoryMetadata> metadata() {
+        return Optional.ofNullable(metadata);
+    }
+
+    @Override
+    public Optional<Path> path() {
+        return Optional.ofNullable(path);
+    }
+
+    @Override
+    public Optional<Repository> repository() {
+        return Optional.ofNullable(repository);
+    }
+
+    @Override
+    public Optional<Exception> exception() {
+        return Optional.ofNullable(exception);
+    }
+
+    @Override
+    public List<Exception> exceptions() {
+        return exceptions;
+    }
+
+    @Override
+    public Optional<RequestTrace> trace() {
+        return Optional.ofNullable(trace);
+    }
+
+    private static final class DefaultRepositoryMetadata implements RepositoryMetadata {
+
+        private final String groupId;
+        private final String artifactId;
+        private final String version;
+        private final String type;
+        private final Nature nature;
+        private final Path path;
+        private final Map<String, String> properties;
+
+        private DefaultRepositoryMetadata(org.eclipse.aether.metadata.Metadata metadata) {
+            this.groupId = metadata.getGroupId();
+            this.artifactId = metadata.getArtifactId();
+            this.version = metadata.getVersion();
+            this.type = metadata.getType();
+            this.nature = Nature.valueOf(metadata.getNature().name());
+            this.path = metadata.getPath();
+            this.properties = Map.copyOf(metadata.getProperties());
+        }
+
+        @Override
+        public String groupId() {
+            return groupId;
+        }
+
+        @Override
+        public String artifactId() {
+            return artifactId;
+        }
+
+        @Override
+        public String version() {
+            return version;
+        }
+
+        @Override
+        public String type() {
+            return type;
+        }
+
+        @Override
+        public Nature nature() {
+            return nature;
+        }
+
+        @Override
+        public Optional<Path> path() {
+            return Optional.ofNullable(path);
+        }
+
+        @Override
+        public Map<String, String> properties() {
+            return properties;
+        }
+    }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/MavenRepositoryListener.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/MavenRepositoryListener.java
new file mode 100644
index 0000000000..c68c80eb41
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/MavenRepositoryListener.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.impl;
+
+import java.util.Collection;
+import java.util.function.BiConsumer;
+
+import org.apache.maven.api.Listener;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Bridges Maven Resolver repository events to the Maven public API.
+ */
+public final class MavenRepositoryListener extends org.eclipse.aether.AbstractRepositoryListener {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(MavenRepositoryListener.class);
+
+    private void dispatch(
+            org.eclipse.aether.RepositoryEvent event, BiConsumer<RepositoryListener, RepositoryEvent> consumer) {
+        Object associatedSession = event.getSession().getData().get(InternalSession.class);
+        if (!(associatedSession instanceof InternalSession session)) {
+            return;
+        }
+        Collection<Listener> listeners = session.getListeners();
+        if (!listeners.isEmpty()) {
+            RepositoryEvent repositoryEvent = null;
+            for (Listener registered : listeners) {
+                if (!(registered instanceof RepositoryListener listener)) {
+                    continue;
+                }
+                if (repositoryEvent == null) {
+                    repositoryEvent = new DefaultRepositoryEvent(session, event);
+                }
+                try {
+                    consumer.accept(listener, repositoryEvent);
+                } catch (RuntimeException e) {
+                    LOGGER.warn(
+                            "Failed to notify repository listener {} about {}",
+                            listener.getClass().getName(),
+                            event.getType(),
+                            e);
+                }
+            }
+        }
+    }
+
+    @Override
+    public void artifactDescriptorInvalid(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactDescriptorInvalid);
+    }
+
+    @Override
+    public void artifactDescriptorMissing(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactDescriptorMissing);
+    }
+
+    @Override
+    public void metadataInvalid(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataInvalid);
+    }
+
+    @Override
+    public void artifactResolving(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactResolving);
+    }
+
+    @Override
+    public void artifactResolved(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactResolved);
+    }
+
+    @Override
+    public void metadataResolving(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataResolving);
+    }
+
+    @Override
+    public void metadataResolved(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataResolved);
+    }
+
+    @Override
+    public void artifactDownloading(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactDownloading);
+    }
+
+    @Override
+    public void artifactDownloaded(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactDownloaded);
+    }
+
+    @Override
+    public void metadataDownloading(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataDownloading);
+    }
+
+    @Override
+    public void metadataDownloaded(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataDownloaded);
+    }
+
+    @Override
+    public void artifactInstalling(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactInstalling);
+    }
+
+    @Override
+    public void artifactInstalled(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactInstalled);
+    }
+
+    @Override
+    public void metadataInstalling(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataInstalling);
+    }
+
+    @Override
+    public void metadataInstalled(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataInstalled);
+    }
+
+    @Override
+    public void artifactDeploying(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactDeploying);
+    }
+
+    @Override
+    public void artifactDeployed(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::artifactDeployed);
+    }
+
+    @Override
+    public void metadataDeploying(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataDeploying);
+    }
+
+    @Override
+    public void metadataDeployed(org.eclipse.aether.RepositoryEvent event) {
+        dispatch(event, RepositoryListener::metadataDeployed);
+    }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java
index fa3faa2e66..98ecb75b9c 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/standalone/ApiRunner.java
@@ -78,6 +78,7 @@
 import org.apache.maven.di.impl.InjectorImpl;
 import org.apache.maven.impl.AbstractSession;
 import org.apache.maven.impl.InternalSession;
+import org.apache.maven.impl.MavenRepositoryListener;
 import org.apache.maven.impl.di.SessionScope;
 import org.apache.maven.impl.model.DefaultInterpolator;
 import org.apache.maven.impl.resolver.MavenSessionBuilderSupplier;
@@ -555,6 +556,7 @@ static Session newSession(RepositorySystem system, Lookup lookup, @Nullable Loca
         // Configure the resolver session with dependency resolution machinery
         MavenSessionBuilderSupplier sessionBuilderSupplier = new MavenSessionBuilderSupplier(system, false);
         DefaultRepositorySystemSession rsession = new DefaultRepositorySystemSession(h -> false);
+        rsession.setRepositoryListener(new MavenRepositoryListener());
         rsession.setScopeManager(new ScopeManagerImpl(Maven4ScopeManagerConfiguration.INSTANCE));
         rsession.setDependencyTraverser(sessionBuilderSupplier.getDependencyTraverser());
         rsession.setDependencyManager(sessionBuilderSupplier.getDependencyManager(true));
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/MavenRepositoryListenerTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/MavenRepositoryListenerTest.java
new file mode 100644
index 0000000000..1dd69aed18
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/MavenRepositoryListenerTest.java
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.impl;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+
+import org.apache.maven.api.Artifact;
+import org.apache.maven.api.Listener;
+import org.apache.maven.api.RemoteRepository;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryEventType;
+import org.apache.maven.api.RepositoryListener;
+import org.apache.maven.api.RepositoryMetadata;
+import org.apache.maven.api.Session;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.metadata.DefaultMetadata;
+import org.eclipse.aether.metadata.Metadata;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MavenRepositoryListenerTest {
+
+    @Test
+    void unifiedRegistrationPreservesBuildListenerLambdas() {
+        Session session = mock(Session.class);
+
+        session.registerListener(event -> {});
+
+        verify(session).registerListener(org.mockito.ArgumentMatchers.any(Listener.class));
+    }
+
+    @Test
+    void ignoresEventsBeforeMavenSessionAssociation() {
+        DefaultRepositorySystemSession resolverSession = new DefaultRepositorySystemSession(h -> false);
+        MavenRepositoryListener bridge = new MavenRepositoryListener();
+
+        bridge.artifactResolving(new org.eclipse.aether.RepositoryEvent.Builder(
+                        resolverSession, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVING)
+                .build());
+    }
+
+    @Test
+    void dispatchesAllRepositoryEventTypes() {
+        TestContext context = new TestContext();
+        RecordingListener listener = new RecordingListener();
+        when(context.session.getListeners()).thenReturn(List.of(listener));
+
+        for (org.eclipse.aether.RepositoryEvent.EventType type :
+                org.eclipse.aether.RepositoryEvent.EventType.values()) {
+            dispatch(
+                    context.bridge,
+                    new org.eclipse.aether.RepositoryEvent.Builder(context.resolverSession, type).build());
+        }
+
+        assertEquals(List.of(RepositoryEventType.values()), listener.types);
+    }
+
+    @Test
+    void convertsArtifactRepositoryFailurePathAndTrace() {
+        TestContext context = new TestContext();
+        RecordingListener listener = new RecordingListener();
+        when(context.session.getListeners()).thenReturn(List.of(listener));
+
+        org.eclipse.aether.artifact.Artifact resolverArtifact =
+                new org.eclipse.aether.artifact.DefaultArtifact("org.example:demo:jar:1.0");
+        Artifact artifact = mock(Artifact.class);
+        when(context.session.getArtifact(resolverArtifact)).thenReturn(artifact);
+        org.eclipse.aether.repository.RemoteRepository resolverRepository =
+                new org.eclipse.aether.repository.RemoteRepository.Builder("central", "default", "https://repo.example")
+                        .build();
+        RemoteRepository repository = mock(RemoteRepository.class);
+        when(context.session.getRepository(resolverRepository)).thenReturn(Optional.of(repository));
+        Exception failure = new Exception("resolution failed");
+        Path path = Path.of("target", "demo.jar");
+
+        context.bridge.artifactResolved(new org.eclipse.aether.RepositoryEvent.Builder(
+                        context.resolverSession, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVED)
+                .setArtifact(resolverArtifact)
+                .setRepository(resolverRepository)
+                .setPath(path)
+                .setException(failure)
+                .setTrace(new org.eclipse.aether.RequestTrace("request"))
+                .build());
+
+        RepositoryEvent event = listener.events.get(0);
+        assertEquals(RepositoryEventType.ARTIFACT_RESOLVED, event.type());
+        assertSame(context.session, event.session());
+        assertSame(artifact, event.artifact().orElseThrow());
+        assertSame(repository, event.repository().orElseThrow());
+        assertEquals(path, event.path().orElseThrow());
+        assertSame(failure, event.exception().orElseThrow());
+        assertEquals(List.of(failure), event.exceptions());
+        assertEquals("request", event.trace().orElseThrow().data());
+        assertTrue(event.metadata().isEmpty());
+        assertThrows(
+                UnsupportedOperationException.class, () -> event.exceptions().add(new Exception()));
+    }
+
+    @Test
+    void convertsMetadataWithoutExposingResolverMetadata() {
+        TestContext context = new TestContext();
+        RecordingListener listener = new RecordingListener();
+        when(context.session.getListeners()).thenReturn(List.of(listener));
+        Path path = Path.of("target", "maven-metadata.xml");
+        Metadata metadata = new DefaultMetadata(
+                "org.example",
+                "demo",
+                "1.0-SNAPSHOT",
+                "maven-metadata.xml",
+                Metadata.Nature.SNAPSHOT,
+                Map.of("source", "test"),
+                path);
+
+        context.bridge.metadataResolved(new org.eclipse.aether.RepositoryEvent.Builder(
+                        context.resolverSession, org.eclipse.aether.RepositoryEvent.EventType.METADATA_RESOLVED)
+                .setMetadata(metadata)
+                .build());
+
+        RepositoryMetadata converted = listener.events.get(0).metadata().orElseThrow();
+        assertEquals("org.example", converted.groupId());
+        assertEquals("demo", converted.artifactId());
+        assertEquals("1.0-SNAPSHOT", converted.version());
+        assertEquals("maven-metadata.xml", converted.type());
+        assertEquals(RepositoryMetadata.Nature.SNAPSHOT, converted.nature());
+        assertEquals(path, converted.path().orElseThrow());
+        assertEquals(Map.of("source", "test"), converted.properties());
+        assertThrows(
+                UnsupportedOperationException.class,
+                () -> converted.properties().put("key", "value"));
+    }
+
+    @Test
+    void isolatesListenerFailures() {
+        TestContext context = new TestContext();
+        RepositoryListener failing = new RepositoryListener() {
+            @Override
+            public void artifactResolving(RepositoryEvent event) {
+                throw new IllegalStateException("listener failure");
+            }
+        };
+        AtomicInteger notifications = new AtomicInteger();
+        RepositoryListener succeeding = new RepositoryListener() {
+            @Override
+            public void artifactResolving(RepositoryEvent event) {
+                notifications.incrementAndGet();
+            }
+        };
+        when(context.session.getListeners()).thenReturn(List.of(failing, succeeding));
+
+        context.bridge.artifactResolving(new org.eclipse.aether.RepositoryEvent.Builder(
+                        context.resolverSession, org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVING)
+                .build());
+
+        assertEquals(1, notifications.get());
+    }
+
+    @Test
+    void supportsConcurrentDispatch() {
+        TestContext context = new TestContext();
+        AtomicInteger notifications = new AtomicInteger();
+        RepositoryListener listener = new RepositoryListener() {
+            @Override
+            public void artifactResolving(RepositoryEvent event) {
+                notifications.incrementAndGet();
+            }
+        };
+        when(context.session.getListeners()).thenReturn(List.of(listener));
+
+        IntStream.range(0, 100)
+                .parallel()
+                .forEach(i -> context.bridge.artifactResolving(new org.eclipse.aether.RepositoryEvent.Builder(
+                                context.resolverSession,
+                                org.eclipse.aether.RepositoryEvent.EventType.ARTIFACT_RESOLVING)
+                        .build()));
+
+        assertEquals(100, notifications.get());
+    }
+
+    private static void dispatch(MavenRepositoryListener listener, org.eclipse.aether.RepositoryEvent event) {
+        switch (event.getType()) {
+            case ARTIFACT_DESCRIPTOR_INVALID -> listener.artifactDescriptorInvalid(event);
+            case ARTIFACT_DESCRIPTOR_MISSING -> listener.artifactDescriptorMissing(event);
+            case METADATA_INVALID -> listener.metadataInvalid(event);
+            case ARTIFACT_RESOLVING -> listener.artifactResolving(event);
+            case ARTIFACT_RESOLVED -> listener.artifactResolved(event);
+            case METADATA_RESOLVING -> listener.metadataResolving(event);
+            case METADATA_RESOLVED -> listener.metadataResolved(event);
+            case ARTIFACT_DOWNLOADING -> listener.artifactDownloading(event);
+            case ARTIFACT_DOWNLOADED -> listener.artifactDownloaded(event);
+            case METADATA_DOWNLOADING -> listener.metadataDownloading(event);
+            case METADATA_DOWNLOADED -> listener.metadataDownloaded(event);
+            case ARTIFACT_INSTALLING -> listener.artifactInstalling(event);
+            case ARTIFACT_INSTALLED -> listener.artifactInstalled(event);
+            case METADATA_INSTALLING -> listener.metadataInstalling(event);
+            case METADATA_INSTALLED -> listener.metadataInstalled(event);
+            case ARTIFACT_DEPLOYING -> listener.artifactDeploying(event);
+            case ARTIFACT_DEPLOYED -> listener.artifactDeployed(event);
+            case METADATA_DEPLOYING -> listener.metadataDeploying(event);
+            case METADATA_DEPLOYED -> listener.metadataDeployed(event);
+            default -> throw new IllegalArgumentException("Unknown repository event type: " + event.getType());
+        }
+    }
+
+    private static final class TestContext {
+        private final DefaultRepositorySystemSession resolverSession = new DefaultRepositorySystemSession(h -> false);
+        private final InternalSession session = mock(InternalSession.class);
+        private final MavenRepositoryListener bridge = new MavenRepositoryListener();
+
+        private TestContext() {
+            InternalSession.associate(resolverSession, session);
+        }
+    }
+
+    private static final class RecordingListener implements RepositoryListener {
+        private final List<RepositoryEventType> types = new ArrayList<>();
+        private final List<RepositoryEvent> events = new ArrayList<>();
+
+        private void record(RepositoryEvent event) {
+            types.add(event.type());
+            events.add(event);
+        }
+
+        @Override
+        public void artifactDescriptorInvalid(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactDescriptorMissing(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataInvalid(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactResolving(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactResolved(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataResolving(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataResolved(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactDownloading(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactDownloaded(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataDownloading(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataDownloaded(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactInstalling(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactInstalled(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataInstalling(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataInstalled(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactDeploying(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void artifactDeployed(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataDeploying(RepositoryEvent event) {
+            record(event);
+        }
+
+        @Override
+        public void metadataDeployed(RepositoryEvent event) {
+            record(event);
+        }
+    }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/standalone/RequestTraceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/standalone/RequestTraceTest.java
index adef91ddb4..05ec85e270 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/standalone/RequestTraceTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/standalone/RequestTraceTest.java
@@ -28,6 +28,8 @@
 import org.apache.maven.api.DownloadedArtifact;
 import org.apache.maven.api.Node;
 import org.apache.maven.api.PathScope;
+import org.apache.maven.api.RepositoryEvent;
+import org.apache.maven.api.RepositoryListener;
 import org.apache.maven.api.Session;
 import org.apache.maven.api.di.Named;
 import org.apache.maven.api.di.Provides;
@@ -38,11 +40,6 @@
 import org.apache.maven.api.services.ModelBuilderResult;
 import org.apache.maven.api.services.RequestTrace;
 import org.apache.maven.api.services.Sources;
-import org.apache.maven.impl.InternalSession;
-import org.apache.maven.impl.RequestTraceHelper;
-import org.eclipse.aether.AbstractRepositoryListener;
-import org.eclipse.aether.DefaultRepositorySystemSession;
-import org.eclipse.aether.RepositoryEvent;
 import org.eclipse.aether.spi.connector.transport.http.ChecksumExtractor;
 import org.eclipse.aether.spi.io.PathProcessor;
 import org.eclipse.aether.transport.apache.ApacheTransporterFactory;
@@ -76,13 +73,13 @@ void testTraces() {
         assertNotNull(result.getEffectiveModel());
 
         List<RepositoryEvent> events = new CopyOnWriteArrayList<>();
-        ((DefaultRepositorySystemSession) InternalSession.from(session).getSession())
-                .setRepositoryListener(new AbstractRepositoryListener() {
-                    @Override
-                    public void artifactResolved(RepositoryEvent event) {
-                        events.add(event);
-                    }
-                });
+        RepositoryListener listener = new RepositoryListener() {
+            @Override
+            public void artifactResolved(RepositoryEvent event) {
+                events.add(event);
+            }
+        };
+        session.registerListener(listener);
 
         ArtifactCoordinates coords =
                 session.createArtifactCoordinates("org.apache.maven:maven-api-core:4.0.0-alpha-13");
@@ -105,14 +102,15 @@ public void artifactResolved(RepositoryEvent event) {
                 .next();
 
         for (RepositoryEvent event : events) {
-            org.eclipse.aether.RequestTrace trace = event.getTrace();
-            assertNotNull(trace);
-
-            RequestTrace rTrace = RequestTraceHelper.toMaven("collect", trace);
-            assertNotNull(rTrace);
+            RequestTrace rTrace = event.trace().orElseThrow();
             assertNotNull(rTrace.parent());
         }
 
+        Session derived = session.withRemoteRepositories(session.getRemoteRepositories());
+        assertTrue(derived.getListeners().contains(listener));
+        derived.unregisterListener(listener);
+        assertTrue(session.getListeners().isEmpty());
+        assertTrue(derived.getListeners().isEmpty());
         assertNotNull(node);
         assertEquals(6, node.getChildren().size());
     }
diff --git a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java
index 41f9394c86..6299fa8abe 100644
--- a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java
+++ b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/SessionStub.java
@@ -206,7 +206,7 @@ public void unregisterListener(Listener listener) {}
 
     @Override
     public Collection<Listener> getListeners() {
-        return null;
+        return List.of();
     }
 
     @Override