acdream/docs/plans/2026-09-07-headless-console.md
Erik 7381112394 docs(headless-console): record the 2026-09-07 Opus fix-round ledger entry
N4 + summary: /status and /quit are console-intercepted verbs (they
never reach ChatCommandRouter), unlike @status which is a real server
command and still passes through untouched. Records the full S1-S7/
N1-N5 fix-round outcome, final Headless (207/1/208) and App LaunchOptions
(4/4) suite counts, and the one-commit-per-item/mutation-shown-to-fail
discipline used throughout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 08:15:35 +02:00

14 KiB

Headless console — an interactive CLI for the bot host

Date: 2026-09-07 Status: ACTIVE (owner direction 2026-09-07: "the headless client should have a CLI as well. Like we have the chat loaded in headless so we can see what it does and we can talk via it if we want and control plugins like /moss bla or /say hello") Belongs with the "one plugin, two hosts" model recorded in docs/plans/2026-09-06-campaign-vt-vtank-oracle.md. Independent of Campaign VT's slices (host side only) — runs in parallel.

What it is

When the headless host runs with a terminal attached (or --console / ACDREAM_HEADLESS_CONSOLE=1, documented in docs/launch-options.md), it becomes a chat-shaped REPL for the session:

  • Output. The ordered event stream rendered one line per event, chat first-class: [Tell] Bob: hi, [Fellowship] …, [Local] …, system text, plugin output (everything a plugin writes through the host log/chat sink), and lifecycle/command outcomes (connected, entered world, portal → Holtburg, command rejected: …). Same channel names and ordering the graphical SpewBox shows; no colors required (ANSI channel colors only when the terminal supports them, off when piped).
  • Input. Each typed line goes through the SAME command pipeline the graphical chat box uses: a /-prefixed line is dispatched to the plugin command registry first (/vt …, any plugin verb) and otherwise to the client/server slash commands (/say, /tell, /t, /f, /a, emotes, /help), a plain line is /say. No second parser, no bot-only shortcuts: it must reuse the exact seam the retained chat box calls (find it — the CH-campaign command registry and the chat-send route), through the Runtime command surface Slice K already exposes ("chat and slash/server commands").
  • Control. /quit (graceful logout, exit 0), /status (session generation, position, macro state if the plugin reports one). Ctrl+C keeps today's graceful behavior.
  • Multi-session. Out of scope for the first cut: the console attaches to the single-session host (K1's production mode). A multi-session prefix (@name …) is a later addition; leave the seam shaped for it (input routed through a session-scoped adapter, not a static).

Rules

  • Headless stays Runtime-only: no App references, no rendering types. If the chat-send or command-dispatch seam currently lives in App, lift the presentation-free part into Runtime/Core with a dependency guard, and leave the graphical client calling the same lifted code (behavior-preserving).
  • Reads of stdin never block the scheduler: a reader thread feeding a thread-safe queue drained on the session tick (Slice K's monotonic scheduler contract).
  • Every line of output goes through one renderer over the typed event stream — the same events the K2 bot API emits — so tests assert on the rendered text without a terminal.
  • Portable: Windows and Linux; no ANSI when Console.IsOutputRedirected.

Tests (Headless test project, no live server)

  • Scripted stdin → rendered stdout: chat lines, a plugin command reaching the plugin's registered verb, /say producing the same outbound message the graphical route produces (assert on the command the fake Runtime received), plain text → say, /quit → graceful teardown, unknown verb → the same error text the chat box prints.
  • Reader-thread/scheduler test: input queued while the tick is busy is drained in order on the next tick, never on the reader thread.
  • Launch-options doc row + the documentation test. One connected proof at closeout (real ACE, testaccount, type /say hello and see it echo; type /vt start with MossTank loaded) — the owner may run it or the lead may, it is not a visual gate.

Ledger

  • 2026-09-07 planned; implementer dispatched.

  • 2026-09-07 IMPLEMENTED. The dispatch seam already existed: AcDream.Runtime.Chat.ChatCommandRouter.Submit is the SAME presentation- free pipeline LoginCommandSequence (headless) and every graphical chat window (ChatWindowController, FloatingChatWindowController, RetailUiRuntime) already call — no lift was needed. Added HeadlessSessionHost.SubmitConsoleLine (Hosting/HeadlessSessionHost.cs) as the one new call site, reusing the host's own retained LiveChatCommandSurface/plugin registry (now promoted from ctor locals to fields) instead of a second parser. New files: Configuration/HeadlessConsoleOptions.cs (typed --console / ACDREAM_HEADLESS_CONSOLE=1 / terminal-default resolution), Hosting/HeadlessConsoleInputReader.cs (background stdin thread → FIFO queue, never executes handler code), Hosting/HeadlessConsoleController.cs (drains the queue on the session tick via a new HeadlessSessionHost. ConsolePump hook; owns /quit//status), Hosting/ HeadlessConsoleChatFormatter.cs + Hosting/HeadlessConsoleRenderer.cs (renders the K2 bot event stream — IRuntimeEventObserver, the same interface a bot policy subscribes — as bracket-labelled lines: [Tell] Bob: hi, [Fellowship] …, [Local] …), Hosting/ HeadlessConsoleChatFeedback.cs (decorates RuntimeChatCommandFeedback so retail's transient SpewBox/ClientLocal interface text — which never touches ChatLog, so it never reaches the K2 event stream — also reaches the console). /quit cancels a CancellationTokenSource linked into the scheduler's run token in HeadlessProcessHost (the SAME graceful-exit path an external Ctrl+C/SIGTERM already takes); /status reports generation, position (or "unknown" without a live movement controller), and loaded-plugin count (no plugin today reports a richer macro-state string). Console only attaches for a single-session run (per the plan's "out of scope for the first cut" multi-session note); constructed AFTER every session's own credential resolution so the reader thread never races a StandardInput-provider password prompt on the same stream. Chosen console default: on when !Console.IsInputRedirected (a real operator at a terminal), off when redirected (scripts/CI/piped fixtures, where a blocked ReadLine on a background thread would just sit idle) — resolved once in Program.cs, the only place that can see the real Console. Deviation from the plan's illustrative example: retail's own transcript never prefixes Tell/Local lines with a bracket (ChatVM.FormatEntry renders "Bob tells you, ..."/"Bob says, ..." with no label) — Headless cannot reference AcDream.UI.Abstractions (the dependency-boundary test), so HeadlessConsoleChatFormatter is a deliberately DIFFERENT, terminal-shaped "[Label] Sender: text" rendering using the SAME channel- name strings (matching the plan's literal [Tell] Bob: hi example), not a byte-for-byte port of the graphical prose. Tests: tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs (20 new tests — options resolution, command-line flag parsing, reader-thread ordering/never-on-reader-thread, controller drain/quit/status, chat formatting, and full /say/plain-text/plugin-verb/unknown-verb dispatch against a real HeadlessSessionHost + FixtureSessionOperations, no live server) plus the existing LaunchOptionsDocumentationTests (4/4 green) and HeadlessDependencyBoundaryTests (3/3 green, unchanged — Headless still references only AcDream.Runtime). Every test in this batch was mutation-checked to fail before the corresponding production line existed (see the implementer's final report for the specific mutations run: skipping the interface-text callback, skipping _quitRequested.Cancel(), forcing TryHandlePluginCommand to always return false, dropping the reader thread's Enqueue, and swapping ChatChannelKind.Say for .Tell in SubmitConsoleLine). Suites: dotnet test tests/AcDream.Headless.Tests -c Release → 193 passed / 1 pre-existing failure (LinuxRejectsGroupOrOtherCredentialPermissions, a Linux-only lane test that cannot run on this Windows host — unrelated to this change) / 194 total. dotnet test tests/AcDream.Runtime.Tests -c Release → 1891/1891 passed. dotnet test tests/AcDream.App.Tests -c Release --filter "FullyQualifiedName~Chat|FullyQualifiedName~Command| FullyQualifiedName~LaunchOptions" → 414 passed / 2 pre-existing failures (ChatIndicatorButtonLiveMountProbeTests/OptionsPanelLiveMountProbeTests — both gated on ACDREAM_PROBE_LIVE_MOUNT=1, a manual live-DAT probe lane, unrelated to this change) / 3 skipped / 419 total. dotnet build AcDream.slnx -c Release green throughout.

  • 2026-09-07 FIX ROUND (Opus review, APPROVE-WITH-FIXES). S1: ACDREAM_ HEADLESS_CONSOLE=0 now disables the console even when stdin is a real terminal — the prior == "1" test let "0" silently fall through to the terminal-shaped default; the flag is now the sixth entry in LaunchOptionsDocumentationTests.DefaultOnBehaviorFlags (a default-on behavior with an A/B off-switch, once set at all, like ACDREAM_RETAIL_CHASE). S2: the reader-thread pin is now falsifiable — a fixture TextReader records the actual thread id ReadLine ran on, and a new test asserts the controller's submit callback runs on neither that thread nor any other unexpected one, only the DrainDue caller's. S3: one HeadlessProcessHost end-to-end test proves a console line reaches the session's real SubmitConsoleLine pipeline and /quit returns HeadlessExitCode.Success. S4: HeadlessConsoleController.Handle now wraps _submit in try/catch (mirroring LoginCommandSequence.DrainDue) and prints a line for UnknownCommand/Dropped, so a console typo can never escape into the scheduler's per-session quarantine catch. S5: deleted the per-call HeadlessConsoleChatFeedback decorator — it only ever saw text produced by the console's OWN SubmitConsoleLine calls. The new HeadlessConsoleSpewBoxPump polls the shared SpewBoxState on the console's own per-tick pump instead, the SAME seam the graphical overlay's SpewBoxController.Tick reads, so server- and plugin-driven ClientLocal interface text prints too. S6: Program.cs now resolves standardOutputIsTerminal next to the stdin probe and threads it through HeadlessEntryPoint.RunHeadlessProcessHost, which no longer reads System.Console.IsOutputRedirected itself. S7: a multi-session process launched with --console now reports _diagnostics.Message("console", "single-session only") instead of silently skipping console attachment. N1: corrected two stale dispatch-order doc comments (HeadlessSessionHost.SubmitConsoleLine, HeadlessConsoleController's class remarks) to the real ChatCommandRouter.Submit order: retail's client-command catalog, local /help, plugin verbs, the unregistered- channel-tag fallback, an explicit server command, then plain chat. N2: HeadlessCommandLine.Console renamed to ConsoleEnabled. N3: validate mode now rejects --console outright rather than silently ignoring it. N4: /status and /quit are console-intercepted verbs — they never reach ChatCommandRouter, unlike @status, which is a real server command and still passes through untouched. N5: HeadlessConsoleRenderer now dims only lifecycle/command/portal lines; chat and interface text print at the terminal's default weight. Every new/changed test was shown to fail first against a targeted mutation of the corresponding production code (see each commit's own body for the specific mutation) before the fix landed; one commit per item, all with Co-Authored-By: Claude Fable 5.1. Suites (Release): dotnet test tests/AcDream.Headless.Tests → 207 passed / 1 pre-existing Linux-lane failure (LinuxRejectsGroupOrOtherCredentialPermissions) / 208 total (up from 193/1/194 before this round — 14 new/changed tests). dotnet test tests/AcDream.App.Tests --filter "FullyQualifiedName~LaunchOptions" → 4/4 passed, including the corrected OnlyTheSixProductBehaviorFlagsDefaultOn (renamed from Five). dotnet build AcDream.slnx -c Release green throughout.

Connected proof recipe (owner runs; NOT run by the implementer)

Against a running local ACE at 127.0.0.1:9000 with MossTank loaded for the second half:

$env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
dotnet run --project src\AcDream.Headless\AcDream.Headless.csproj --no-build -c Release -- `
  run --config <path-to-a-one-session-config.json> `
  -user testaccount -password testpassword --console

The referenced config's one session should target character +Acdream (server guid 0x5000000A) against 127.0.0.1:9000, an idle bot policy, and (for the second half) the MossTank plugin id under plugins. Once the console prints entered world:

  1. Type /say hello and press Enter — expect the SAME line ACE echoes back to any other observer (a retail client or a second acdream session watching +Acdream) to also print [Local] You: hello in this console (the server's own HearSpeech echo, rendered through the normal chat event stream).
  2. Type /status — expect a line with generation=, position= (a real cell/local-frame triple once in world), and plugins=N loaded.
  3. With MossTank loaded, type /vt start (or whatever verb MossTank registers) — expect MossTank's own handler to run (check its own status/log output) and confirm NOTHING was sent to the wire for that line (no @vt server command).
  4. Type /quit — expect a graceful ACE logout (same as the existing Ctrl+C behavior) and the process to exit 0.

This is not a visual gate; the owner (or the lead) runs it opportunistically before considering the plan CLOSED.