diff --git a/src/ModularPipelines.Azure.Pipelines/AzurePipeline.cs b/src/ModularPipelines.Azure.Pipelines/AzurePipeline.cs
index 483ac81958..afdfa6f448 100644
--- a/src/ModularPipelines.Azure.Pipelines/AzurePipeline.cs
+++ b/src/ModularPipelines.Azure.Pipelines/AzurePipeline.cs
@@ -1,20 +1,25 @@
+using ModularPipelines.Console;
using ModularPipelines.Context;
-using ModularPipelines.Logging;
+using ModularPipelines.Engine;
namespace ModularPipelines.Azure.Pipelines;
internal class AzurePipeline : IAzurePipeline
{
private readonly IEnvironmentContext _environment;
- private readonly IModuleOutputWriter _outputWriter;
+ private readonly IModuleOutputBuffer _buffer;
+ private readonly IBuildSystemFormatter _formatter;
- public AzurePipeline(AzurePipelineVariables variables,
+ public AzurePipeline(
+ AzurePipelineVariables variables,
IEnvironmentContext environment,
- IModuleOutputWriterFactory outputWriterFactory)
+ IConsoleCoordinator consoleCoordinator,
+ IBuildSystemFormatterProvider formatterProvider)
{
_environment = environment;
Variables = variables;
- _outputWriter = outputWriterFactory.Create("AzurePipeline");
+ _buffer = consoleCoordinator.GetUnattributedBuffer();
+ _formatter = formatterProvider.GetFormatter();
}
public bool IsRunningOnAzurePipelines
@@ -24,16 +29,40 @@ public bool IsRunningOnAzurePipelines
public void WriteLine(string message)
{
- _outputWriter.WriteLine(message);
+ _buffer.WriteLine(message);
}
- public void WriteLineDirect(string message)
+ public IDisposable BeginSection(string name)
{
- _outputWriter.WriteLineDirect(message);
+ return new OutputSection(_buffer, name, _formatter);
}
- public IDisposable BeginSection(string name)
+ private sealed class OutputSection : IDisposable
{
- return _outputWriter.BeginSection(name);
+ private readonly IModuleOutputBuffer _buffer;
+ private readonly string _name;
+ private readonly IBuildSystemFormatter _formatter;
+
+ public OutputSection(IModuleOutputBuffer buffer, string name, IBuildSystemFormatter formatter)
+ {
+ _buffer = buffer;
+ _name = name;
+ _formatter = formatter;
+
+ var startCommand = formatter.GetStartBlockCommand(name);
+ if (startCommand != null)
+ {
+ _buffer.WriteLine(startCommand);
+ }
+ }
+
+ public void Dispose()
+ {
+ var endCommand = _formatter.GetEndBlockCommand(_name);
+ if (endCommand != null)
+ {
+ _buffer.WriteLine(endCommand);
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/ModularPipelines.Azure.Pipelines/IAzurePipeline.cs b/src/ModularPipelines.Azure.Pipelines/IAzurePipeline.cs
index 6a9319bcf3..c9e8ff8b75 100644
--- a/src/ModularPipelines.Azure.Pipelines/IAzurePipeline.cs
+++ b/src/ModularPipelines.Azure.Pipelines/IAzurePipeline.cs
@@ -1,10 +1,21 @@
-using ModularPipelines.Logging;
-
namespace ModularPipelines.Azure.Pipelines;
-public interface IAzurePipeline : IModuleOutputWriter
+public interface IAzurePipeline
{
public bool IsRunningOnAzurePipelines { get; }
public AzurePipelineVariables Variables { get; }
-}
\ No newline at end of file
+
+ ///
+ /// Writes a message to the console output.
+ ///
+ /// The message to write.
+ void WriteLine(string message);
+
+ ///
+ /// Begins a collapsible section in CI output.
+ ///
+ /// The section name.
+ /// A disposable that ends the section when disposed.
+ IDisposable BeginSection(string name);
+}
diff --git a/src/ModularPipelines.GitHub/GitHub.cs b/src/ModularPipelines.GitHub/GitHub.cs
index 88ef6f3063..df84954ac2 100644
--- a/src/ModularPipelines.GitHub/GitHub.cs
+++ b/src/ModularPipelines.GitHub/GitHub.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Options;
+using ModularPipelines.Console;
+using ModularPipelines.Engine;
using ModularPipelines.GitHub.Options;
-using ModularPipelines.Logging;
using Octokit;
using Octokit.Internal;
@@ -11,7 +12,8 @@ internal class GitHub : IGitHub
private readonly GitHubOptions _options;
private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory;
private readonly Lazy _client;
- private readonly ModuleOutputWriter _outputWriter;
+ private readonly IModuleOutputBuffer _buffer;
+ private readonly IBuildSystemFormatter _formatter;
public IGitHubClient Client => _client.Value;
@@ -24,31 +26,26 @@ public GitHub(
IGitHubEnvironmentVariables environmentVariables,
IGitHubRepositoryInfo gitHubRepositoryInfo,
IHttpMessageHandlerFactory httpMessageHandlerFactory,
- IModuleOutputWriterFactory outputWriterFactory,
- IModuleLoggerProvider moduleLoggerProvider)
+ IConsoleCoordinator consoleCoordinator,
+ IBuildSystemFormatterProvider formatterProvider)
{
_options = options.Value;
_httpMessageHandlerFactory = httpMessageHandlerFactory;
EnvironmentVariables = environmentVariables;
-
_client = new Lazy(InitializeClient);
RepositoryInfo = gitHubRepositoryInfo;
- _outputWriter = outputWriterFactory.Create("GitHub", moduleLoggerProvider.GetLogger());
+ _buffer = consoleCoordinator.GetUnattributedBuffer();
+ _formatter = formatterProvider.GetFormatter();
}
public void WriteLine(string message)
{
- _outputWriter.WriteLine(message);
- }
-
- public void WriteLineDirect(string message)
- {
- _outputWriter.WriteLineDirect(message);
+ _buffer.WriteLine(message);
}
public IDisposable BeginSection(string name)
{
- return _outputWriter.BeginSection(name);
+ return new OutputSection(this, name, _formatter);
}
// PRIVATE METHODS
@@ -74,4 +71,33 @@ private IGitHubClient InitializeClient()
return client;
}
+
+ private sealed class OutputSection : IDisposable
+ {
+ private readonly GitHub _github;
+ private readonly string _name;
+ private readonly IBuildSystemFormatter _formatter;
+
+ public OutputSection(GitHub github, string name, IBuildSystemFormatter formatter)
+ {
+ _github = github;
+ _name = name;
+ _formatter = formatter;
+
+ var startCommand = formatter.GetStartBlockCommand(name);
+ if (startCommand != null)
+ {
+ _github._buffer.WriteLine(startCommand);
+ }
+ }
+
+ public void Dispose()
+ {
+ var endCommand = _formatter.GetEndBlockCommand(_name);
+ if (endCommand != null)
+ {
+ _github._buffer.WriteLine(endCommand);
+ }
+ }
+ }
}
diff --git a/src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs b/src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs
index c85ef25ac4..b1ff96bc32 100644
--- a/src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs
+++ b/src/ModularPipelines.GitHub/GitHubMarkdownSummaryGenerator.cs
@@ -50,7 +50,7 @@ private async Task WriteFile(IPipelineHookContext pipelineContext, string stepSu
if (newSize > MaxFileSizeInBytes)
{
- Console.WriteLine("Appending to the GitHub Step Summary would exceed the 1MB file size limit.");
+ System.Console.WriteLine("Appending to the GitHub Step Summary would exceed the 1MB file size limit.");
return;
}
diff --git a/src/ModularPipelines.GitHub/GitHubRepositoryInfo.cs b/src/ModularPipelines.GitHub/GitHubRepositoryInfo.cs
index 7ef9bcf678..d15588b333 100644
--- a/src/ModularPipelines.GitHub/GitHubRepositoryInfo.cs
+++ b/src/ModularPipelines.GitHub/GitHubRepositoryInfo.cs
@@ -98,7 +98,7 @@ public async Task InitializeAsync()
}
catch (Exception e) when (e is not (OutOfMemoryException or StackOverflowException))
{
- Console.WriteLine(e);
+ System.Console.WriteLine(e);
}
}
}
diff --git a/src/ModularPipelines.GitHub/IGitHub.cs b/src/ModularPipelines.GitHub/IGitHub.cs
index afe7ba1be7..0520d05c4b 100644
--- a/src/ModularPipelines.GitHub/IGitHub.cs
+++ b/src/ModularPipelines.GitHub/IGitHub.cs
@@ -1,13 +1,25 @@
-using ModularPipelines.Logging;
using Octokit;
namespace ModularPipelines.GitHub;
-public interface IGitHub : IModuleOutputWriter
+public interface IGitHub
{
IGitHubClient Client { get; }
IGitHubRepositoryInfo RepositoryInfo { get; }
IGitHubEnvironmentVariables EnvironmentVariables { get; }
-}
\ No newline at end of file
+
+ ///
+ /// Writes a message to the console output.
+ ///
+ /// The message to write.
+ void WriteLine(string message);
+
+ ///
+ /// Begins a collapsible section in CI output (GitHub Actions group).
+ ///
+ /// The section name.
+ /// A disposable that ends the section when disposed.
+ IDisposable BeginSection(string name);
+}
diff --git a/src/ModularPipelines.TeamCity/ITeamCity.cs b/src/ModularPipelines.TeamCity/ITeamCity.cs
index d9738edb36..ab14e359d6 100644
--- a/src/ModularPipelines.TeamCity/ITeamCity.cs
+++ b/src/ModularPipelines.TeamCity/ITeamCity.cs
@@ -1,8 +1,19 @@
-using ModularPipelines.Logging;
-
namespace ModularPipelines.TeamCity;
-public interface ITeamCity : IModuleOutputWriter
+public interface ITeamCity
{
ITeamCityEnvironmentVariables EnvironmentVariables { get; }
-}
\ No newline at end of file
+
+ ///
+ /// Writes a message to the console output.
+ ///
+ /// The message to write.
+ void WriteLine(string message);
+
+ ///
+ /// Begins a collapsible section in CI output.
+ ///
+ /// The section name.
+ /// A disposable that ends the section when disposed.
+ IDisposable BeginSection(string name);
+}
diff --git a/src/ModularPipelines.TeamCity/TeamCity.cs b/src/ModularPipelines.TeamCity/TeamCity.cs
index 1302e66901..d797cf3d27 100644
--- a/src/ModularPipelines.TeamCity/TeamCity.cs
+++ b/src/ModularPipelines.TeamCity/TeamCity.cs
@@ -1,32 +1,61 @@
-using ModularPipelines.Logging;
+using ModularPipelines.Console;
+using ModularPipelines.Engine;
namespace ModularPipelines.TeamCity;
internal class TeamCity : ITeamCity
{
- private readonly ModuleOutputWriter _outputWriter;
+ private readonly IModuleOutputBuffer _buffer;
+ private readonly IBuildSystemFormatter _formatter;
- public TeamCity(ITeamCityEnvironmentVariables environmentVariables,
- IModuleOutputWriterFactory outputWriterFactory)
+ public TeamCity(
+ ITeamCityEnvironmentVariables environmentVariables,
+ IConsoleCoordinator consoleCoordinator,
+ IBuildSystemFormatterProvider formatterProvider)
{
EnvironmentVariables = environmentVariables;
- _outputWriter = outputWriterFactory.Create("TeamCity");
+ _buffer = consoleCoordinator.GetUnattributedBuffer();
+ _formatter = formatterProvider.GetFormatter();
}
public ITeamCityEnvironmentVariables EnvironmentVariables { get; }
public void WriteLine(string message)
{
- _outputWriter.WriteLine(message);
+ _buffer.WriteLine(message);
}
- public void WriteLineDirect(string message)
+ public IDisposable BeginSection(string name)
{
- _outputWriter.WriteLineDirect(message);
+ return new OutputSection(_buffer, name, _formatter);
}
- public IDisposable BeginSection(string name)
+ private sealed class OutputSection : IDisposable
{
- return _outputWriter.BeginSection(name);
+ private readonly IModuleOutputBuffer _buffer;
+ private readonly string _name;
+ private readonly IBuildSystemFormatter _formatter;
+
+ public OutputSection(IModuleOutputBuffer buffer, string name, IBuildSystemFormatter formatter)
+ {
+ _buffer = buffer;
+ _name = name;
+ _formatter = formatter;
+
+ var startCommand = formatter.GetStartBlockCommand(name);
+ if (startCommand != null)
+ {
+ _buffer.WriteLine(startCommand);
+ }
+ }
+
+ public void Dispose()
+ {
+ var endCommand = _formatter.GetEndBlockCommand(_name);
+ if (endCommand != null)
+ {
+ _buffer.WriteLine(endCommand);
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/ModularPipelines/Console/ConsoleCoordinator.cs b/src/ModularPipelines/Console/ConsoleCoordinator.cs
new file mode 100644
index 0000000000..df60f2ac2a
--- /dev/null
+++ b/src/ModularPipelines/Console/ConsoleCoordinator.cs
@@ -0,0 +1,402 @@
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModularPipelines.Engine;
+using ModularPipelines.Helpers;
+using ModularPipelines.Models;
+using ModularPipelines.Modules;
+using ModularPipelines.Options;
+using Spectre.Console;
+
+namespace ModularPipelines.Console;
+
+///
+/// Central implementation that owns all console output.
+///
+///
+///
+/// Architecture: This coordinator is the single point of control for all console output.
+/// It intercepts Console.Out/Error to catch rogue writes and routes them to appropriate buffers.
+/// Spectre.Console is configured to write directly to the real console for progress rendering.
+///
+///
+/// Thread Safety: This class is thread-safe. All methods can be called
+/// concurrently from multiple threads.
+///
+///
+/// IProgressDisplay: Implements IProgressDisplay to integrate with existing notification
+/// system. The ProgressPrinter forwards notifications here, which are delegated to the active session.
+///
+///
+[ExcludeFromCodeCoverage]
+internal class ConsoleCoordinator : IConsoleCoordinator, IProgressDisplay
+{
+ private readonly IBuildSystemFormatterProvider _formatterProvider;
+ private readonly IResultsPrinter _resultsPrinter;
+ private readonly ISecretObfuscator _secretObfuscator;
+ private readonly IOptions _options;
+ private readonly ILoggerFactory _loggerFactory;
+
+ // Console state
+ private TextWriter? _originalConsoleOut;
+ private TextWriter? _originalConsoleError;
+ private IAnsiConsole? _originalAnsiConsole;
+ private CoordinatedTextWriter? _coordinatedOut;
+ private CoordinatedTextWriter? _coordinatedError;
+
+ // Phase management
+ private volatile bool _isProgressActive;
+ private readonly object _phaseLock = new();
+ private bool _isInstalled;
+ private IProgressSession? _activeSession;
+
+ // Module buffers
+ private readonly ConcurrentDictionary _moduleBuffers = new();
+ private readonly ModuleOutputBuffer _unattributedBuffer;
+
+ // Deferred exceptions
+ private readonly ConcurrentQueue _deferredExceptions = new();
+
+ // Logger for output
+ private ILogger? _outputLogger;
+
+ public ConsoleCoordinator(
+ IBuildSystemFormatterProvider formatterProvider,
+ IResultsPrinter resultsPrinter,
+ ISecretObfuscator secretObfuscator,
+ IOptions options,
+ ILoggerFactory loggerFactory)
+ {
+ _formatterProvider = formatterProvider;
+ _resultsPrinter = resultsPrinter;
+ _secretObfuscator = secretObfuscator;
+ _options = options;
+ _loggerFactory = loggerFactory;
+ _unattributedBuffer = new ModuleOutputBuffer("Pipeline", typeof(void));
+ }
+
+ ///
+ public void Install()
+ {
+ lock (_phaseLock)
+ {
+ if (_isInstalled)
+ {
+ throw new InvalidOperationException("ConsoleCoordinator is already installed.");
+ }
+
+ // Save original streams before any modifications
+ var originalOut = System.Console.Out;
+ var originalError = System.Console.Error;
+ var originalAnsi = AnsiConsole.Console;
+
+ try
+ {
+ _originalConsoleOut = originalOut;
+ _originalConsoleError = originalError;
+ _originalAnsiConsole = originalAnsi;
+
+ // Configure Spectre.Console to use the REAL console directly
+ // This bypasses our interception for progress rendering
+ AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Out = new AnsiConsoleOutput(_originalConsoleOut)
+ });
+
+ // Create logger for structured output during flush
+ _outputLogger = _loggerFactory.CreateLogger("ModularPipelines.Output");
+
+ // Install our intercepting writers
+ _coordinatedOut = new CoordinatedTextWriter(
+ this,
+ _originalConsoleOut,
+ () => _isProgressActive,
+ _secretObfuscator);
+
+ _coordinatedError = new CoordinatedTextWriter(
+ this,
+ _originalConsoleError,
+ () => _isProgressActive,
+ _secretObfuscator);
+
+ System.Console.SetOut(_coordinatedOut);
+ System.Console.SetError(_coordinatedError);
+
+ _isInstalled = true;
+ }
+ catch
+ {
+ // Restore original streams on failure
+ System.Console.SetOut(originalOut);
+ System.Console.SetError(originalError);
+ AnsiConsole.Console = originalAnsi;
+
+ _originalConsoleOut = null;
+ _originalConsoleError = null;
+ _originalAnsiConsole = null;
+ _coordinatedOut = null;
+ _coordinatedError = null;
+
+ throw;
+ }
+ }
+ }
+
+ ///
+ public void Uninstall()
+ {
+ lock (_phaseLock)
+ {
+ if (!_isInstalled)
+ {
+ return;
+ }
+
+ // Flush any remaining buffered content
+ _coordinatedOut?.Flush();
+ _coordinatedError?.Flush();
+
+ // Restore original streams
+ if (_originalConsoleOut != null)
+ {
+ System.Console.SetOut(_originalConsoleOut);
+ }
+
+ if (_originalConsoleError != null)
+ {
+ System.Console.SetError(_originalConsoleError);
+ }
+
+ if (_originalAnsiConsole != null)
+ {
+ AnsiConsole.Console = _originalAnsiConsole;
+ }
+
+ _originalConsoleOut = null;
+ _originalConsoleError = null;
+ _originalAnsiConsole = null;
+ _coordinatedOut = null;
+ _coordinatedError = null;
+ _isInstalled = false;
+ }
+ }
+
+ ///
+ public async Task BeginProgressAsync(
+ OrganizedModules modules,
+ CancellationToken cancellationToken)
+ {
+ if (!_options.Value.ShowProgressInConsole)
+ {
+ // Return a no-op session if progress is disabled
+ _activeSession = new NoOpProgressSession();
+ return _activeSession;
+ }
+
+ lock (_phaseLock)
+ {
+ if (_isProgressActive)
+ {
+ throw new InvalidOperationException("Progress session is already active.");
+ }
+
+ _isProgressActive = true;
+ }
+
+ var session = new ProgressSession(
+ this,
+ modules,
+ _options,
+ cancellationToken);
+
+ _activeSession = session;
+
+ // Start the progress display
+ session.Start();
+
+ return session;
+ }
+
+ ///
+ /// Ends the progress phase. Called by ProgressSession.DisposeAsync().
+ ///
+ internal void EndProgressPhase()
+ {
+ lock (_phaseLock)
+ {
+ _isProgressActive = false;
+ _activeSession = null;
+ }
+ }
+
+ ///
+ public IModuleOutputBuffer GetModuleBuffer(Type moduleType)
+ {
+ return _moduleBuffers.GetOrAdd(moduleType, t => new ModuleOutputBuffer(t));
+ }
+
+ ///
+ public IModuleOutputBuffer GetUnattributedBuffer() => _unattributedBuffer;
+
+ ///
+ public void FlushModuleOutput()
+ {
+ if (_originalConsoleOut == null)
+ {
+ throw new InvalidOperationException("ConsoleCoordinator is not installed.");
+ }
+
+ var formatter = _formatterProvider.GetFormatter();
+
+ // Flush unattributed output first (if any)
+ if (_unattributedBuffer.HasOutput)
+ {
+ var unattributedLogger = _outputLogger ?? _loggerFactory.CreateLogger("ModularPipelines.Output");
+ _unattributedBuffer.FlushTo(_originalConsoleOut, formatter, unattributedLogger);
+ }
+
+ // Flush module buffers in completion order
+ var orderedBuffers = _moduleBuffers.Values
+ .Where(b => b.HasOutput)
+ .OrderBy(b => b.CompletedAtUtc ?? DateTime.MaxValue)
+ .ToList();
+
+ foreach (var buffer in orderedBuffers)
+ {
+ // Create logger with the correct module category for proper log replay
+ var moduleLogger = _loggerFactory.CreateLogger(buffer.ModuleType);
+ buffer.FlushTo(_originalConsoleOut, formatter, moduleLogger);
+ }
+
+ // Clear buffers after flush to release memory
+ // This prevents accumulation in long-running pipelines
+ _moduleBuffers.Clear();
+ }
+
+ ///
+ public void WriteResults(PipelineSummary summary)
+ {
+ // Results printer uses AnsiConsole which we've configured
+ // to write directly to the real console
+ _resultsPrinter.PrintResults(summary);
+ }
+
+ ///
+ public void AddDeferredException(string message)
+ {
+ _deferredExceptions.Enqueue(message);
+ }
+
+ ///
+ public void WriteExceptions()
+ {
+ if (_deferredExceptions.IsEmpty)
+ {
+ return;
+ }
+
+ var messages = new List();
+ while (_deferredExceptions.TryDequeue(out var message))
+ {
+ messages.Add(message);
+ }
+
+ if (messages.Count == 0)
+ {
+ return;
+ }
+
+ // Write using AnsiConsole (goes to real console)
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[bold yellow]\u26a0[/] [bold red]Deferred Exceptions[/]");
+ AnsiConsole.WriteLine();
+
+ foreach (var message in messages)
+ {
+ AnsiConsole.WriteLine(message);
+ }
+
+ AnsiConsole.WriteLine();
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ // Flush any buffered module output before uninstalling
+ // This ensures logs are written even when pipeline isn't executed (e.g., in tests)
+ if (_isInstalled)
+ {
+ try
+ {
+ FlushModuleOutput();
+ }
+ catch (InvalidOperationException)
+ {
+ // Ignore if not installed - FlushModuleOutput requires installation
+ }
+ }
+
+ Uninstall();
+ await Task.CompletedTask;
+ }
+
+ #region IProgressDisplay Implementation
+
+ ///
+ /// Implements IProgressDisplay.RunAsync for integration with existing notification system.
+ /// This is called by ProgressPrinter when the old code path is used.
+ ///
+ async Task IProgressDisplay.RunAsync(OrganizedModules organizedModules, CancellationToken cancellationToken)
+ {
+ // Install if not already installed (for backward compatibility)
+ if (!_isInstalled)
+ {
+ Install();
+ }
+
+ await using var session = await BeginProgressAsync(organizedModules, cancellationToken).ConfigureAwait(false);
+
+ // Wait for cancellation - the session runs until disposed
+ try
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected - progress ends when cancelled
+ }
+ }
+
+ ///
+ void IProgressDisplay.OnModuleStarted(ModuleState moduleState, TimeSpan estimatedDuration)
+ {
+ _activeSession?.OnModuleStarted(moduleState, estimatedDuration);
+ }
+
+ ///
+ void IProgressDisplay.OnModuleCompleted(ModuleState moduleState, bool isSuccessful)
+ {
+ _activeSession?.OnModuleCompleted(moduleState, isSuccessful);
+ }
+
+ ///
+ void IProgressDisplay.OnModuleSkipped(ModuleState moduleState)
+ {
+ _activeSession?.OnModuleSkipped(moduleState);
+ }
+
+ ///
+ void IProgressDisplay.OnSubModuleCreated(IModule parentModule, SubModuleBase subModule, TimeSpan estimatedDuration)
+ {
+ _activeSession?.OnSubModuleCreated(parentModule, subModule, estimatedDuration);
+ }
+
+ ///
+ void IProgressDisplay.OnSubModuleCompleted(SubModuleBase subModule, bool isSuccessful)
+ {
+ _activeSession?.OnSubModuleCompleted(subModule, isSuccessful);
+ }
+
+ #endregion
+}
diff --git a/src/ModularPipelines/Console/CoordinatedTextWriter.cs b/src/ModularPipelines/Console/CoordinatedTextWriter.cs
new file mode 100644
index 0000000000..944ee646e0
--- /dev/null
+++ b/src/ModularPipelines/Console/CoordinatedTextWriter.cs
@@ -0,0 +1,194 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using ModularPipelines.Engine;
+using ModularPipelines.Logging;
+
+namespace ModularPipelines.Console;
+
+///
+/// Intercepts Console.Out/Error writes and routes them through the coordinator.
+///
+///
+///
+/// Purpose: This writer replaces Console.Out/Error to catch all direct
+/// console writes. During progress phase, writes are buffered per-module.
+/// After progress ends, writes pass through directly.
+///
+///
+/// Module Detection: Uses (AsyncLocal)
+/// to detect which module (if any) is currently executing. This allows Console.WriteLine
+/// calls inside modules to be attributed to the correct module's output buffer.
+///
+///
+/// Thread Safety: This class is thread-safe. All operations are either
+/// read-only or delegated to thread-safe components.
+///
+///
+[ExcludeFromCodeCoverage]
+internal class CoordinatedTextWriter : TextWriter
+{
+ private readonly IConsoleCoordinator _coordinator;
+ private readonly TextWriter _realConsole;
+ private readonly Func _isProgressActive;
+ private readonly ISecretObfuscator _secretObfuscator;
+ private readonly StringBuilder _lineBuffer = new();
+ private readonly object _lineBufferLock = new();
+
+ ///
+ /// Initializes a new coordinated text writer.
+ ///
+ /// The console coordinator.
+ /// The real console to write to when not buffering.
+ /// Function that returns whether progress is active.
+ /// Obfuscator for secrets in output.
+ public CoordinatedTextWriter(
+ IConsoleCoordinator coordinator,
+ TextWriter realConsole,
+ Func isProgressActive,
+ ISecretObfuscator secretObfuscator)
+ {
+ _coordinator = coordinator;
+ _realConsole = realConsole;
+ _isProgressActive = isProgressActive;
+ _secretObfuscator = secretObfuscator;
+ }
+
+ ///
+ public override Encoding Encoding => _realConsole.Encoding;
+
+ ///
+ public override void WriteLine(string? value)
+ {
+ var message = value ?? string.Empty;
+ var obfuscated = _secretObfuscator.Obfuscate(message, null);
+
+ if (!_isProgressActive())
+ {
+ // Progress not running - write directly
+ _realConsole.WriteLine(obfuscated);
+ return;
+ }
+
+ // Progress is active - buffer the output
+ RouteToBuffer(obfuscated);
+ }
+
+ ///
+ public override void WriteLine()
+ {
+ WriteLine(string.Empty);
+ }
+
+ ///
+ public override void Write(string? value)
+ {
+ if (value == null)
+ {
+ return;
+ }
+
+ var obfuscated = _secretObfuscator.Obfuscate(value, null);
+
+ if (!_isProgressActive())
+ {
+ // Progress not running - write directly
+ _realConsole.Write(obfuscated);
+ return;
+ }
+
+ // Progress is active - accumulate until newline
+ lock (_lineBufferLock)
+ {
+ foreach (var c in obfuscated)
+ {
+ if (c == '\n')
+ {
+ // Flush the line
+ var line = _lineBuffer.ToString().TrimEnd('\r');
+ _lineBuffer.Clear();
+ RouteToBuffer(line);
+ }
+ else
+ {
+ _lineBuffer.Append(c);
+ }
+ }
+ }
+ }
+
+ ///
+ public override void Write(char value)
+ {
+ Write(value.ToString());
+ }
+
+ ///
+ public override void Write(char[] buffer, int index, int count)
+ {
+ Write(new string(buffer, index, count));
+ }
+
+ ///
+ /// Routes a message to the appropriate buffer based on current module context.
+ ///
+ private void RouteToBuffer(string message)
+ {
+ var currentModule = ModuleLogger.CurrentModuleType.Value;
+
+ if (currentModule != null)
+ {
+ // Inside a module - route to that module's buffer
+ var buffer = _coordinator.GetModuleBuffer(currentModule);
+ buffer.WriteLine(message);
+ }
+ else
+ {
+ // Outside any module - route to unattributed buffer
+ _coordinator.GetUnattributedBuffer().WriteLine(message);
+ }
+ }
+
+ ///
+ public override void Flush()
+ {
+ // Flush any partial line in the buffer
+ lock (_lineBufferLock)
+ {
+ if (_lineBuffer.Length > 0)
+ {
+ var line = _lineBuffer.ToString();
+ _lineBuffer.Clear();
+
+ if (_isProgressActive())
+ {
+ RouteToBuffer(line);
+ }
+ else
+ {
+ _realConsole.Write(line);
+ }
+ }
+ }
+
+ // Always flush real console (needed for Spectre.Console internals)
+ _realConsole.Flush();
+ }
+
+ ///
+ public override Task FlushAsync()
+ {
+ Flush();
+ return _realConsole.FlushAsync();
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ Flush();
+ }
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/src/ModularPipelines/Console/IConsoleCoordinator.cs b/src/ModularPipelines/Console/IConsoleCoordinator.cs
new file mode 100644
index 0000000000..ab3153c411
--- /dev/null
+++ b/src/ModularPipelines/Console/IConsoleCoordinator.cs
@@ -0,0 +1,104 @@
+using ModularPipelines.Models;
+
+namespace ModularPipelines.Console;
+
+///
+/// Owns all console output for the pipeline lifecycle.
+/// Provides buffering, phase management, and module-aware output routing.
+///
+///
+///
+/// Architecture: This coordinator is the single point of control for all console output.
+/// It intercepts Console.Out/Error to catch rogue writes and routes them to appropriate buffers.
+///
+///
+/// Lifecycle:
+/// 1. Install() - Intercepts Console.Out/Error
+/// 2. BeginProgressAsync() - Starts progress display phase
+/// 3. [Pipeline executes] - All output buffered per-module
+/// 4. ProgressSession disposed - Ends progress phase
+/// 5. FlushModuleOutput() - Writes buffered output in order
+/// 6. WriteResults() - Prints results table
+/// 7. WriteExceptions() - Prints deferred exceptions
+/// 8. Uninstall() - Restores original console
+///
+///
+/// Thread Safety: This class is thread-safe. All methods can be called
+/// concurrently from multiple threads.
+///
+///
+internal interface IConsoleCoordinator : IAsyncDisposable
+{
+ ///
+ /// Installs the coordinator, intercepting Console.Out/Error.
+ /// Must be called before pipeline execution begins.
+ ///
+ ///
+ /// After installation:
+ /// - Console.Out/Error are replaced with coordinated writers
+ /// - AnsiConsole is configured to write directly to the real console
+ /// - All Console.Write* calls are intercepted and routed appropriately
+ ///
+ /// Thrown if already installed.
+ void Install();
+
+ ///
+ /// Begins the progress display phase.
+ ///
+ ///
+ /// During the progress phase:
+ /// - Only Spectre.Console (via AnsiConsole) writes directly to console
+ /// - All other output is buffered per-module using AsyncLocal context
+ /// - The phase ends when the returned session is disposed
+ ///
+ /// The organized modules for progress tracking.
+ /// Cancellation token.
+ /// A progress session that must be disposed to end the progress phase.
+ /// Thrown if progress is already active.
+ Task BeginProgressAsync(OrganizedModules modules, CancellationToken cancellationToken);
+
+ ///
+ /// Gets the output buffer for a specific module.
+ /// Creates the buffer if it doesn't exist.
+ ///
+ /// The module type.
+ /// The module's output buffer.
+ IModuleOutputBuffer GetModuleBuffer(Type moduleType);
+
+ ///
+ /// Gets the buffer for output that occurs outside any module context.
+ ///
+ /// The unattributed output buffer.
+ IModuleOutputBuffer GetUnattributedBuffer();
+
+ ///
+ /// Flushes all module output in completion order.
+ /// Should be called after progress phase ends.
+ ///
+ /// Thrown if not installed.
+ void FlushModuleOutput();
+
+ ///
+ /// Writes the pipeline results table.
+ ///
+ /// The pipeline summary to display.
+ void WriteResults(PipelineSummary summary);
+
+ ///
+ /// Adds an exception message for deferred output.
+ ///
+ /// The formatted exception message.
+ void AddDeferredException(string message);
+
+ ///
+ /// Writes any deferred exceptions.
+ /// Should be called after WriteResults().
+ ///
+ void WriteExceptions();
+
+ ///
+ /// Restores original Console.Out/Error.
+ /// Safe to call multiple times.
+ ///
+ void Uninstall();
+}
diff --git a/src/ModularPipelines/Console/IModuleOutputBuffer.cs b/src/ModularPipelines/Console/IModuleOutputBuffer.cs
new file mode 100644
index 0000000000..cf7f97a582
--- /dev/null
+++ b/src/ModularPipelines/Console/IModuleOutputBuffer.cs
@@ -0,0 +1,83 @@
+using Microsoft.Extensions.Logging;
+using ModularPipelines.Engine;
+
+namespace ModularPipelines.Console;
+
+///
+/// Buffers all output for a single module.
+///
+///
+///
+/// Purpose: Consolidates all module output into a single buffer:
+/// logger output, Console.WriteLine interceptions, and explicit writes.
+///
+///
+/// Thread Safety: All methods are thread-safe and can be called concurrently.
+///
+///
+/// Flush Ordering: Buffers are flushed in completion order (by CompletedAtUtc)
+/// to maintain logical output sequence.
+///
+///
+internal interface IModuleOutputBuffer
+{
+ ///
+ /// Gets the module type this buffer belongs to.
+ ///
+ Type ModuleType { get; }
+
+ ///
+ /// Gets when the module completed (for ordering during flush).
+ /// Null if not yet completed.
+ ///
+ DateTime? CompletedAtUtc { get; }
+
+ ///
+ /// Records completion time for flush ordering.
+ /// Called when the module finishes execution.
+ ///
+ void MarkCompleted();
+
+ ///
+ /// Adds a plain string line to the buffer.
+ /// Used for Console.WriteLine interceptions.
+ ///
+ /// The message to buffer.
+ void WriteLine(string message);
+
+ ///
+ /// Adds a structured log event to the buffer.
+ /// Used for ILogger calls.
+ ///
+ /// Log level.
+ /// Event identifier.
+ /// Log state object.
+ /// Optional exception.
+ /// Formatter function.
+ void AddLogEvent(
+ LogLevel level,
+ EventId eventId,
+ object state,
+ Exception? exception,
+ Func