merge: headless console — interactive chat/command CLI for the bot host (review-closed)

Owner direction 2026-09-07. Reader thread → tick-drained queue, the same
ChatCommandRouter.Submit the chat box uses, event-stream renderer,
SpewBox pump, --console / ACDREAM_HEADLESS_CONSOLE (=0 disables).
Opus review APPROVE-WITH-FIXES, 12-item fix round, narrow re-check
MERGE-READY.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 08:25:22 +02:00
commit 8cb284d6f7
15 changed files with 1728 additions and 17 deletions

View file

@ -4,7 +4,8 @@ internal sealed record HeadlessCommandLine(
string Command,
string ConfigurationPath,
HeadlessPathOverrides Paths,
HeadlessDirectCredentials? DirectCredentials)
HeadlessDirectCredentials? DirectCredentials,
bool ConsoleEnabled = false)
{
internal static HeadlessCommandLine Parse(
IReadOnlyList<string> arguments)
@ -23,15 +24,26 @@ internal sealed record HeadlessCommandLine(
string? cacheDirectory = null;
string? user = null;
string? password = null;
for (int index = 1; index < arguments.Count; index += 2)
bool console = false;
int index = 1;
while (index < arguments.Count)
{
string name = arguments[index];
// --console is a bare flag (no value token) — the interactive
// console for the run command (see HeadlessConsoleOptions).
if (name == "--console")
{
console = true;
index += 1;
continue;
}
if (index + 1 >= arguments.Count)
{
throw new HeadlessCommandLineException(
"Every command option requires a value.");
}
string name = arguments[index];
string value = arguments[index + 1];
if (string.IsNullOrWhiteSpace(value))
{
@ -65,6 +77,7 @@ internal sealed record HeadlessCommandLine(
throw new HeadlessCommandLineException(
"Unknown command option.");
}
index += 2;
}
if (configurationPath is null)
@ -82,6 +95,14 @@ internal sealed record HeadlessCommandLine(
throw new HeadlessCommandLineException(
"Direct credentials are valid only for run mode.");
}
// N3: reject rather than silently ignore --console for validate mode
// — validate never starts a session, so there is nothing for the
// console to attach to.
if (console && command != "run")
{
throw new HeadlessCommandLineException(
"--console is valid only for run mode.");
}
return new HeadlessCommandLine(
command,
@ -92,7 +113,8 @@ internal sealed record HeadlessCommandLine(
cacheDirectory),
user is null
? null
: new HeadlessDirectCredentials(user, password!));
: new HeadlessDirectCredentials(user, password!),
console);
}
private static void SetOnce(ref string? destination, string value)

View file

@ -0,0 +1,53 @@
namespace AcDream.Headless.Configuration;
/// <summary>
/// Typed resolution for the headless interactive console (docs/plans/
/// 2026-09-07-headless-console.md). Three inputs, first match wins:
/// the <c>--console</c> command-line flag, the
/// <c>ACDREAM_HEADLESS_CONSOLE</c> environment variable, and finally a
/// terminal-shaped default — on when stdin is a real console (an operator
/// typing at a keyboard), off when it is redirected (a script, CI runner, or
/// piped fixture, where a background reader thread blocked on
/// <c>ReadLine</c> would never see input and would just sit idle). See
/// docs/launch-options.md for the documented row this owns.
/// </summary>
/// <remarks>
/// S1 fix (2026-09-07 review round): the environment variable is a
/// default-on override once it is SET at all, not a bare "equals 1" test —
/// <c>ACDREAM_HEADLESS_CONSOLE=0</c> must disable the console even when
/// stdin is a real terminal, matching the
/// <c>ACDREAM_RETAIL_CLOSE_DEGRADES</c> / <c>ACDREAM_RETAIL_UI</c>
/// convention (any value other than the literal string <c>"0"</c> enables).
/// An UNSET variable still falls through to the terminal-shaped default —
/// this flag's "default on" is conditional on stdin, unlike those two, but
/// once set at all it behaves identically.
/// </remarks>
internal static class HeadlessConsoleOptions
{
internal const string EnvironmentVariable = "ACDREAM_HEADLESS_CONSOLE";
internal static bool Resolve(
bool commandLineFlag,
bool standardInputIsTerminal) =>
Resolve(
commandLineFlag,
Environment.GetEnvironmentVariable,
standardInputIsTerminal);
internal static bool Resolve(
bool commandLineFlag,
Func<string, string?> env,
bool standardInputIsTerminal)
{
ArgumentNullException.ThrowIfNull(env);
if (commandLineFlag)
return true;
if (env(EnvironmentVariable) is null)
return standardInputIsTerminal;
// Default-on once the flag is set at all: any value other than the
// literal string "0" enables the console — the same
// ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI idiom.
return !string.Equals(
env("ACDREAM_HEADLESS_CONSOLE"), "0", StringComparison.Ordinal);
}
}

View file

@ -46,7 +46,9 @@ internal static class HeadlessEntryPoint
TextReader standardInput,
TextWriter output,
TextWriter error,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool standardInputIsTerminal = false,
bool standardOutputIsTerminal = false)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(standardInput);
@ -74,13 +76,18 @@ internal static class HeadlessEntryPoint
configuredPaths.Merge(commandLine.Paths));
if (commandLine.Command == "run")
{
bool consoleEnabled = HeadlessConsoleOptions.Resolve(
commandLine.ConsoleEnabled,
standardInputIsTerminal);
using var host = new HeadlessProcessHost(
configuration,
paths,
standardInput,
output,
directCredentials:
commandLine.DirectCredentials);
commandLine.DirectCredentials,
consoleEnabled: consoleEnabled,
standardOutputIsTerminal: standardOutputIsTerminal);
return (int)host.RunAsync(cancellationToken)
.GetAwaiter()
.GetResult();

View file

@ -0,0 +1,58 @@
using AcDream.Core.Chat;
using AcDream.Runtime;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Presentation for the console's rendered chat lines. A distinct, terminal-
/// shaped format from the graphical <c>ChatVM.FormatEntry</c> retail prose
/// (Headless cannot reference <c>AcDream.UI.Abstractions</c> — see the
/// dependency-boundary test — and a script piping console output wants a
/// stable, greppable "[Label] Sender: text" shape more than retail's exact
/// sentence). It uses the SAME channel-name strings the graphical SpewBox
/// shows (<see cref="RuntimeChatEntry.ChannelName"/>, "Tell", "Local") per
/// the plan's requirement, just not the same sentence template.
/// </summary>
internal static class HeadlessConsoleChatFormatter
{
/// <summary>Formats one chat event for the console, or
/// <see langword="null"/> when this kind renders nothing (there are
/// none today — kept for forward compatibility with a future silent
/// kind).</summary>
internal static string? Format(in RuntimeChatEntry entry)
{
var kind = (ChatKind)entry.Kind;
return kind switch
{
ChatKind.LocalSpeech or ChatKind.RangedSpeech =>
$"[Local] {SpeakerLabel(entry.Sender)}: {entry.Text}",
ChatKind.Channel =>
$"[{ChannelLabel(entry)}] {SpeakerLabel(entry.Sender)}: {entry.Text}",
ChatKind.Tell => FormatTell(entry),
ChatKind.Emote or ChatKind.SoulEmote =>
$"* {entry.Sender} {entry.Text}",
ChatKind.Popup => $"[Popup] {entry.Text}",
// System/Combat lines arrive pre-formatted (system messages,
// combat translator output) — render bare, matching retail's own
// no-prefix system-chat convention (Campaign CH user-gate round
// 1, item B).
_ => entry.Text,
};
}
private static string FormatTell(in RuntimeChatEntry entry) =>
// SenderGuid != 0 is an incoming whisper (see ChatLog.OnTellReceived);
// == 0 is our own outbound echo, where Sender carries the target
// name (ChatLog.OnSelfSent). Both directions get the "[Tell]" label
// the plan asks for; the "You -> " marker is what disambiguates an
// outgoing tell from an incoming one in the bracket-label shape.
entry.SenderGuid != 0
? $"[Tell] {entry.Sender}: {entry.Text}"
: $"[Tell] You -> {entry.Sender}: {entry.Text}";
private static string SpeakerLabel(string sender) =>
string.IsNullOrEmpty(sender) || sender == "You" ? "You" : sender;
private static string ChannelLabel(in RuntimeChatEntry entry) =>
string.IsNullOrEmpty(entry.ChannelName) ? "Channel" : entry.ChannelName;
}

View file

@ -0,0 +1,116 @@
using AcDream.Runtime.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// The console's own orchestration: owns the background reader
/// (<see cref="HeadlessConsoleInputReader"/>) and, once per session tick
/// (<see cref="DrainDue"/>), drains every line queued since the last call
/// and dispatches each one IN ORDER, on the calling thread — never the
/// reader thread (Slice K's monotonic scheduler contract; see
/// <see cref="HeadlessConsoleInputReader"/>'s own doc).
/// </summary>
/// <remarks>
/// <c>/quit</c> and <c>/status</c> are console-only controls (the plan's
/// "Control" section) — they never reach <see cref="ChatCommandRouter"/>,
/// matching retail's own client-local commands. Every other line goes
/// through <paramref name="submit"/>, which a production caller binds to
/// <c>HeadlessSessionHost.SubmitConsoleLine</c> — the exact
/// <see cref="ChatCommandRouter.Submit"/> pipeline (retail's client-command
/// catalog first, then local <c>/help</c>, then the plugin-verb registry,
/// then the retail unregistered-channel-tag fallback, then an explicit
/// server command, then plain chat) <c>LoginCommandSequence</c> and the
/// graphical chat box both already use.
/// </remarks>
internal sealed class HeadlessConsoleController : IDisposable
{
private readonly HeadlessConsoleInputReader _reader;
private readonly TextWriter _output;
private readonly Func<string, SubmitOutcome> _submit;
private readonly Func<string> _statusText;
private readonly CancellationTokenSource _quitRequested;
internal HeadlessConsoleController(
TextReader input,
TextWriter output,
Func<string, SubmitOutcome> submit,
Func<string> statusText,
CancellationTokenSource quitRequested)
{
ArgumentNullException.ThrowIfNull(input);
_output = output ?? throw new ArgumentNullException(nameof(output));
_submit = submit ?? throw new ArgumentNullException(nameof(submit));
_statusText = statusText ?? throw new ArgumentNullException(nameof(statusText));
_quitRequested = quitRequested
?? throw new ArgumentNullException(nameof(quitRequested));
_reader = new HeadlessConsoleInputReader(input);
}
/// <summary>Number of lines handled by the most recent
/// <see cref="DrainDue"/> call — a test seam for the reader-thread
/// ordering assertion.</summary>
internal int LastDrainCount { get; private set; }
/// <summary>Test seam: lets a bounded-fixture test wait for the
/// background reader thread to reach EOF before calling
/// <see cref="DrainDue"/>, instead of sleeping or polling.</summary>
internal HeadlessConsoleInputReader Reader => _reader;
internal void DrainDue()
{
int count = 0;
while (_reader.TryDequeue(out string line))
{
Handle(line);
count++;
}
LastDrainCount = count;
}
private void Handle(string rawLine)
{
string trimmed = rawLine.Trim();
if (trimmed.Length == 0)
return;
if (trimmed.Equals("/quit", StringComparison.OrdinalIgnoreCase))
{
WriteLine("quitting (graceful logout)");
_quitRequested.Cancel();
return;
}
if (trimmed.Equals("/status", StringComparison.OrdinalIgnoreCase))
{
WriteLine(_statusText());
return;
}
// S4 (2026-09-07 review round): mirrors
// LoginCommandSequence.DrainDue's own try/catch and
// UnknownCommand/Dropped reporting — a console typo (a bad line, a
// downstream bug in a plugin verb handler) must never escape to the
// scheduler's per-session quarantine catch and fault the whole
// session, and the operator deserves the same "this line did
// nothing" signal LoginCommandSequence already gives a login-line
// failure.
try
{
SubmitOutcome outcome = _submit(rawLine);
if (outcome is SubmitOutcome.UnknownCommand or SubmitOutcome.Dropped)
WriteLine($"not handled ({outcome}): {rawLine}");
}
catch (Exception error)
{
WriteLine($"command failed: {error.GetBaseException().Message}");
}
}
private void WriteLine(string text)
{
_output.WriteLine(text);
_output.Flush();
}
public void Dispose() => _reader.Dispose();
}

View file

@ -0,0 +1,94 @@
using System.Collections.Concurrent;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Reads lines from a <see cref="TextReader"/> on one dedicated background
/// thread and hands them to whoever drains <see cref="TryDequeue"/>. Slice K's
/// scheduler contract binds every mutating call to one thread for a session's
/// whole lifetime (#368 — collision generations refuse migration), so console
/// input can never be executed from this thread: it only ever enqueues, and
/// the session tick is the sole reader of <see cref="TryDequeue"/>.
/// </summary>
/// <remarks>
/// <see cref="TextReader.ReadLine"/> has no cancellable overload, so a real
/// <c>Console.In</c> reader can be blocked on it when the process wants to
/// exit. The thread is a background thread (does not keep the process alive)
/// and <see cref="Dispose"/> only requests the loop stop at its next
/// opportunity — it does not abort a pending read. A closed/EOF input (a
/// piped fixture reaching its last line, or the real console's stdin handle
/// closing) ends the loop on its own; <see cref="EndOfInput"/> lets a test
/// wait for that deterministically instead of polling or sleeping.
/// </remarks>
internal sealed class HeadlessConsoleInputReader : IDisposable
{
private readonly TextReader _input;
private readonly ConcurrentQueue<string> _queue = new();
private readonly Thread _thread;
private volatile bool _stopRequested;
internal HeadlessConsoleInputReader(TextReader input)
{
_input = input ?? throw new ArgumentNullException(nameof(input));
_thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "acdream-headless-console-reader",
};
_thread.Start();
}
/// <summary>Set once the reader loop has returned (EOF or stop request).
/// Tests wait on this instead of sleeping/polling for a deterministic
/// "every line the fixture will ever produce has been enqueued" signal.
/// </summary>
internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false);
/// <summary>Dequeues the next queued line in FIFO order, or returns
/// <see langword="false"/> if none is queued yet. Never blocks.</summary>
internal bool TryDequeue(out string line) => _queue.TryDequeue(out line!);
private void ReadLoop()
{
try
{
while (!_stopRequested)
{
string? line = _input.ReadLine();
if (line is null)
return;
_queue.Enqueue(line);
}
}
catch (ObjectDisposedException)
{
// The input was disposed out from under a pending read (process
// teardown racing the reader thread) — end the loop quietly,
// same as EOF.
}
catch (IOException)
{
// A redirected stream can fail mid-read (e.g. a broken pipe).
// Treat it the same as EOF rather than crashing the process.
}
finally
{
EndOfInput.Set();
}
}
/// <summary>Requests the read loop stop at its next opportunity. Does
/// not abort a <see cref="TextReader.ReadLine"/> already in progress —
/// the thread is background, so it cannot block process exit.
/// Deliberately does NOT dispose <see cref="EndOfInput"/>: the read
/// loop's own <c>finally</c> sets it from the reader thread, and racing
/// that against a Dispose() here (an unhandled
/// <see cref="ObjectDisposedException"/> on a background thread
/// terminates the process) is worse than leaking one small
/// synchronization handle for the process's remaining lifetime.
/// </summary>
public void Dispose()
{
_stopRequested = true;
}
}

View file

@ -0,0 +1,111 @@
using AcDream.Runtime;
namespace AcDream.Headless.Hosting;
/// <summary>
/// One presentation over the K2 bot event stream
/// (<see cref="IRuntimeEventObserver"/>) — the SAME typed events a headless
/// bot policy observes (<c>HeadlessBotPolicy.cs</c>) — rendered as plain
/// lines. Every write goes through <see cref="WriteLine"/>, so a test can
/// assert on exactly what a real console would have printed without a
/// terminal.
/// </summary>
internal sealed class HeadlessConsoleRenderer : IRuntimeEventObserver
{
private const string Reset = "";
private const string Dim = "";
private readonly TextWriter _output;
private readonly bool _useColor;
internal HeadlessConsoleRenderer(TextWriter output, bool useColor)
{
_output = output ?? throw new ArgumentNullException(nameof(output));
_useColor = useColor;
}
public void OnChat(in RuntimeChatDelta delta)
{
string? line = HeadlessConsoleChatFormatter.Format(delta.Entry);
if (!string.IsNullOrEmpty(line))
WriteLine(line, dim: false);
}
/// <summary>
/// Retail's transient "interface text" (SpewBox, <c>ClientLocal</c>
/// type) never touches <see cref="RuntimeCommunicationState.Chat"/> —
/// see <c>RuntimeCommunicationState.AddText</c> — so it never reaches
/// <see cref="OnChat"/>. <c>HeadlessConsoleSpewBoxPump</c> calls this
/// directly, once per console tick, for whatever text is newly visible
/// in the polled <see cref="AcDream.Core.Chat.SpewBoxState"/> — the
/// SAME seam the graphical overlay's own SpewBox controller reads, so
/// server- and plugin-driven interface text prints here too, not only
/// the console's own submissions. Default weight (N5) — this is
/// player-visible interface text, not scheduling noise.
/// </summary>
internal void WriteInterfaceText(string text) => WriteLine(text, dim: false);
public void OnLifecycle(in RuntimeLifecycleDelta delta)
{
switch (delta.Current)
{
case RuntimeLifecycleState.InWorld:
WriteLine("entered world", dim: true);
break;
case RuntimeLifecycleState.Stopping:
WriteLine("disconnecting", dim: true);
break;
case RuntimeLifecycleState.Faulted:
WriteLine("session faulted", dim: true);
break;
}
}
public void OnCommand(in RuntimeCommandDelta delta)
{
if (delta.Status == RuntimeCommandStatus.Rejected)
{
WriteLine(
$"command rejected: {delta.Domain} {delta.Text}".TrimEnd(),
dim: true);
}
}
public void OnPortal(in RuntimePortalDelta delta)
{
if (delta.Portal.IsMaterialized)
{
WriteLine(
$"portal -> cell 0x{delta.Portal.DestinationCell:X8}",
dim: true);
}
}
public void OnEntity(in RuntimeEntityDelta delta)
{
}
public void OnInventory(in RuntimeInventoryDelta delta)
{
}
public void OnMovement(in RuntimeMovementDelta delta)
{
}
public void OnCombat(in RuntimeCombatDelta delta)
{
}
/// <summary>
/// N5 (2026-09-07 review round): only lifecycle/command/portal lines are
/// dimmed — scheduling and session-status noise, not player-visible
/// content. Chat and interface text print at the terminal's default
/// weight.
/// </summary>
private void WriteLine(string text, bool dim)
{
_output.WriteLine(_useColor && dim ? Dim + text + Reset : text);
_output.Flush();
}
}

View file

@ -0,0 +1,63 @@
using AcDream.Core.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md):
/// polls <see cref="SpewBoxState"/> on the console's own per-tick pump — the
/// SAME seam <c>AcDream.App.UI.SpewBoxController.Tick</c> drives for the
/// graphical overlay. Retail's transient "interface text"
/// (<see cref="RetailLogTextType.ClientLocal"/>, routed by
/// <c>RuntimeCommunicationState.AddText</c>) never touches
/// <c>RuntimeCommunicationState.Chat</c>/<c>RuntimeChatDelta</c>, so it is
/// otherwise invisible to a console that only observes the chat event
/// stream — this is true for EVERY producer of that text (a bad-args
/// refusal from the console's own submit, but also a server-driven refusal
/// or a plugin's own interface-text write), not just the console's own
/// submissions. This replaces the earlier per-call
/// <c>HeadlessConsoleChatFeedback</c> decorator, which only ever saw text
/// produced by the console's own <c>SubmitConsoleLine</c> calls.
/// </summary>
internal sealed class HeadlessConsoleSpewBoxPump
{
private readonly SpewBoxState _spewBox;
private readonly Func<double> _nowSeconds;
private readonly Action<string> _writeInterfaceText;
private SpewBoxEntry[] _lastSeen = [];
internal HeadlessConsoleSpewBoxPump(
SpewBoxState spewBox,
Func<double> nowSeconds,
Action<string> writeInterfaceText)
{
_spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox));
_nowSeconds = nowSeconds
?? throw new ArgumentNullException(nameof(nowSeconds));
_writeInterfaceText = writeInterfaceText
?? throw new ArgumentNullException(nameof(writeInterfaceText));
}
/// <summary>
/// Drains any pending SpewBox text into the visible set (exactly
/// <see cref="SpewBoxState.Tick"/>'s contract — the same drain
/// <c>SpewBoxVM.Lines</c> performs for the graphical overlay) and prints
/// any entry that was not part of the previous call's visible snapshot.
/// </summary>
/// <remarks>
/// <see cref="SpewBoxState.Snapshot"/> is newest-first
/// (retail's <c>InsertItem(item, 0)</c>); this walks it back-to-front so
/// newly-visible entries print in the order they were actually
/// enqueued, not newest-first.
/// </remarks>
internal void Pump()
{
_spewBox.Tick(_nowSeconds());
SpewBoxEntry[] current = _spewBox.Snapshot();
for (int i = current.Length - 1; i >= 0; i--)
{
if (Array.IndexOf(_lastSeen, current[i]) < 0)
_writeInterfaceText(current[i].Text);
}
_lastSeen = current;
}
}

View file

@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable
private readonly HeadlessDiagnosticWriter _diagnostics;
private readonly HeadlessProcessContentOwner? _content;
private readonly HeadlessProcessResourceSampler _resources;
/// <summary>
/// Headless console (docs/plans/2026-09-07-headless-console.md): always
/// created, cancelled only by <c>/quit</c> — linking it into the
/// scheduler's run token below costs nothing when the console is
/// disabled (it simply never fires) and keeps <see cref="RunOnUpdateThread"/>
/// free of a console-shaped branch.
/// </summary>
private readonly CancellationTokenSource _consoleQuitRequested = new();
private readonly HeadlessConsoleController? _console;
private readonly IDisposable? _consoleRendererSubscription;
private int _disposeIndex;
private bool _disposed;
@ -26,7 +36,9 @@ internal sealed class HeadlessProcessHost : IDisposable
ILiveSessionOperations? sessionOperations = null,
TimeProvider? timeProvider = null,
IHeadlessProcessContentFactory? contentFactory = null,
HeadlessDirectCredentials? directCredentials = null)
HeadlessDirectCredentials? directCredentials = null,
bool consoleEnabled = false,
bool standardOutputIsTerminal = false)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(paths);
@ -65,6 +77,8 @@ internal sealed class HeadlessProcessHost : IDisposable
paths.VtankProfilesDirectory);
HeadlessProcessContentOwner? content = null;
HeadlessProcessResourceSampler? resources = null;
HeadlessConsoleController? console = null;
IDisposable? consoleRendererSubscription = null;
// FA6: constructed unconditionally — cheap, and every non-gate
// session simply never reads or writes it (see the coordinator's
// own class doc).
@ -137,9 +151,62 @@ internal sealed class HeadlessProcessHost : IDisposable
_resources = resources;
_content = content;
_disposeIndex = _sessions.Length - 1;
// Headless console (docs/plans/2026-09-07-headless-console.md):
// "Multi-session. Out of scope for the first cut" — attach only
// to a single-session process. Constructed AFTER every
// session's credential resolution above (which may itself read
// a line from standardInput for a StandardInput-provider
// credential) so the console's own reader thread never races a
// password prompt for the same stream.
if (consoleEnabled && _sessions.Length == 1)
{
HeadlessSessionHost session = _sessions[0];
var renderer = new HeadlessConsoleRenderer(
diagnostics,
useColor: standardOutputIsTerminal);
consoleRendererSubscription =
session.Runtime.Subscribe(renderer);
HeadlessConsoleController controller = new(
standardInput,
diagnostics,
session.SubmitConsoleLine,
() => BuildStatusText(session),
_consoleQuitRequested);
// S5 (2026-09-07 review round): poll the SAME SpewBoxState
// seam the graphical overlay's SpewBoxController.Tick reads
// (RuntimeCommunicationState.AddText's ClientLocal branch —
// it never touches Chat/RuntimeChatDelta) so server- and
// plugin-driven interface text prints too, not only the
// console's own submissions. Replaces the earlier per-call
// HeadlessConsoleChatFeedback decorator, which only saw text
// produced by THIS console's own SubmitConsoleLine calls.
var spewPump = new HeadlessConsoleSpewBoxPump(
session.Runtime.CommunicationOwner.SpewBox,
() => session.Runtime.Clock.SimulationTimeSeconds,
renderer.WriteInterfaceText);
session.ConsolePump = () =>
{
controller.DrainDue();
spewPump.Pump();
};
console = controller;
}
else if (consoleEnabled)
{
// S7 (2026-09-07 review round): a silent skip here read as
// "--console worked" to an operator with no way to tell
// otherwise — the launcher's multi-session mode is a
// legitimate, common configuration, so say so explicitly.
_diagnostics.Message("console", "single-session only");
}
_console = console;
_consoleRendererSubscription = consoleRendererSubscription;
}
catch
{
console?.Dispose();
consoleRendererSubscription?.Dispose();
resources?.Dispose();
for (int index = sessions.Count - 1; index >= 0; index--)
sessions[index].Dispose();
@ -148,6 +215,29 @@ internal sealed class HeadlessProcessHost : IDisposable
}
}
/// <summary>
/// <c>/status</c>: generation, position (or "unknown" without a live
/// movement controller — a content-less host, or before the first
/// accepted placement), and the plugin-visible macro state this host
/// can actually observe today (loaded-plugin count — no plugin
/// currently reports a richer status string; see the plan's "if the
/// plugin reports one").
/// </summary>
private static string BuildStatusText(HeadlessSessionHost session)
{
RuntimeMovementSnapshot movement =
session.Runtime.MovementOwner.Snapshot;
string position = movement.HasController
? $"cell=0x{movement.Position.ObjCellId:X8} "
+ $"local=({movement.Position.Frame.Origin.X:F2},"
+ $"{movement.Position.Frame.Origin.Y:F2},"
+ $"{movement.Position.Frame.Origin.Z:F2})"
: "unknown";
return $"generation={session.Runtime.Generation.Value} "
+ $"position={position} "
+ $"plugins={session.Plugins.LoadedCount} loaded";
}
internal HeadlessSessionHost Session => _sessions.Length == 1
? _sessions[0]
: throw new InvalidOperationException(
@ -249,12 +339,21 @@ internal sealed class HeadlessProcessHost : IDisposable
_scheduler.CaptureSnapshot(),
_content);
// Headless console: /quit cancels _consoleQuitRequested, which this
// linked token propagates into the scheduler's own wait loop —
// Run() returns normally (its loop condition simply goes false),
// the SAME graceful-exit path an external Ctrl+C/SIGTERM already
// takes. Linking costs nothing when the console never fires.
using CancellationTokenSource linkedQuit =
CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
_consoleQuitRequested.Token);
try
{
_scheduler.Run(cancellationToken);
_scheduler.Run(linkedQuit.Token);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
when (linkedQuit.IsCancellationRequested)
{
}
catch (Exception error)
@ -277,6 +376,9 @@ internal sealed class HeadlessProcessHost : IDisposable
{
if (_disposed)
return;
_console?.Dispose();
_consoleRendererSubscription?.Dispose();
_consoleQuitRequested.Dispose();
while (_disposeIndex >= 0)
{
_sessions[_disposeIndex].Dispose();

View file

@ -183,6 +183,25 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IHeadlessBotPolicy _policy;
private readonly IDisposable _policySubscription;
private readonly HeadlessPluginSession _pluginSession;
/// <summary>
/// Headless console (docs/plans/2026-09-07-headless-console.md): the
/// SAME plugin-verb registry <see cref="_chatCommandSurface"/>'s bus
/// forwards to (via <c>TryHandlePluginCommand</c>) and
/// <see cref="HeadlessPluginSession.Create"/> hands to every loaded
/// plugin. Exposed only so a test can register a verb directly without
/// loading a real plugin assembly — production callers reach it
/// exclusively through <see cref="SubmitConsoleLine"/> /
/// <see cref="LoginCommandSequence"/>, never this field.
/// </summary>
private readonly AcDream.Core.Plugins.PluginCommandRegistry _pluginCommands;
/// <summary>
/// Headless console: the SAME retained bus <c>LoginCommandSequence</c>
/// submits through — see <see cref="SubmitConsoleLine"/>. One instance
/// for the host's whole lifetime; <see cref="CreateEventRoute"/>
/// attaches/detaches a fresh <see cref="LiveChatCommandRoute"/> to it on
/// every (re)connect, exactly as it does today for login commands.
/// </summary>
private readonly LiveChatCommandSurface _chatCommandSurface;
private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
@ -453,6 +472,8 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime = runtime;
Commands = commands;
_liveSession = liveSession;
_pluginCommands = pluginCommands;
_chatCommandSurface = chatCommandSurface;
_statusWriter = statusWriter;
_localPlayerFrame =
runtime.CreateLocalPlayerFrameController(
@ -521,7 +542,20 @@ internal sealed class HeadlessSessionHost : IDisposable
/// </summary>
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
internal HeadlessPluginSession Plugins => _pluginSession;
/// <summary>Test seam (mirrors <see cref="OptionsSeeder"/>'s own
/// pattern): registers a plugin verb directly against the SAME registry
/// a real loaded plugin would use, without loading a plugin assembly.
/// </summary>
internal AcDream.Core.Plugins.PluginCommandRegistry PluginCommands =>
_pluginCommands;
internal string SessionId => _descriptor.Id;
/// <summary>
/// Headless console: invoked at the end of every <see cref="Tick"/> so
/// console input drains ON the session tick, in order, never on the
/// reader thread. <see langword="null"/> (every non-console host) costs
/// nothing extra per tick.
/// </summary>
internal Action? ConsolePump { get; set; }
internal string ActiveCharacterName { get; private set; } =
string.Empty;
internal bool IsPolicyComplete =>
@ -563,6 +597,31 @@ internal sealed class HeadlessSessionHost : IDisposable
_pendingConfirmation = null;
}
/// <summary>
/// The headless console's ONE entry point for a typed line — the exact
/// pipeline <see cref="LoginCommandSequence"/> already submits through:
/// <see cref="ChatCommandRouter.Submit"/> against this host's retained
/// <see cref="_chatCommandSurface"/>. Dispatch order (matching
/// <see cref="ChatCommandRouter"/>'s own class doc): retail's client-
/// command catalog first, then the local <c>/help</c> presentation
/// command, then the plugin-verb registry, then the retail unregistered-
/// channel-tag fallback, then an explicit server command, then plain
/// chat. Retail's transient interface text (bad-args refusals, unknown-
/// command text — never routed through
/// <see cref="AcDream.Runtime.RuntimeChatDelta"/>, see
/// <c>RuntimeCommunicationState.AddText</c>'s <c>ClientLocal</c> branch)
/// lands in the shared <see cref="RuntimeCommunicationState.SpewBox"/>
/// exactly like every other producer of that text; the console's own
/// per-tick pump polls it (see <c>HeadlessConsoleSpewBoxPump</c>)
/// instead of this call decorating its own feedback.
/// </summary>
internal SubmitOutcome SubmitConsoleLine(string line) =>
ChatCommandRouter.Submit(
line,
new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
_chatCommandSurface,
ChatChannelKind.Say);
internal RuntimeSessionStartResult Start()
{
// Campaign LA slice LA1: "started" = session host start — the
@ -607,6 +666,10 @@ internal sealed class HeadlessSessionHost : IDisposable
_localPlayerFrame.RunPostNetworkCommandPhase();
Runtime.ActionOwner.CombatAttack.Tick();
_policy.Tick(Runtime, Commands);
// Headless console: drain any input queued by the background reader
// thread since the last tick, in order, on THIS thread — never the
// reader thread (see HeadlessConsoleInputReader's own doc).
ConsolePump?.Invoke();
}
internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped")

View file

@ -27,7 +27,9 @@ try
Console.In,
Console.Out,
Console.Error,
cancellation.Token);
cancellation.Token,
standardInputIsTerminal: !Console.IsInputRedirected,
standardOutputIsTerminal: !Console.IsOutputRedirected);
}
finally
{