14 KiB
Headless console — an interactive CLI for the bot host
Date: 2026-09-07
Status: CLOSED 2026-09-07 — merged 8cb284d6f, connected proof passed (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,
/sayproducing 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 helloand see it echo; type/vt startwith 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.Submitis the SAME presentation- free pipelineLoginCommandSequence(headless) and every graphical chat window (ChatWindowController,FloatingChatWindowController,RetailUiRuntime) already call — no lift was needed. AddedHeadlessSessionHost.SubmitConsoleLine(Hosting/HeadlessSessionHost.cs) as the one new call site, reusing the host's own retainedLiveChatCommandSurface/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 newHeadlessSessionHost. ConsolePumphook; 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(decoratesRuntimeChatCommandFeedbackso retail's transient SpewBox/ClientLocalinterface text — which never touchesChatLog, so it never reaches the K2 event stream — also reaches the console)./quitcancels aCancellationTokenSourcelinked into the scheduler's run token inHeadlessProcessHost(the SAME graceful-exit path an external Ctrl+C/SIGTERM already takes);/statusreports 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-sessionrun(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 aStandardInput-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 blockedReadLineon a background thread would just sit idle) — resolved once inProgram.cs, the only place that can see the realConsole. Deviation from the plan's illustrative example: retail's own transcript never prefixes Tell/Local lines with a bracket (ChatVM.FormatEntryrenders "Bob tells you, ..."/"Bob says, ..." with no label) — Headless cannot referenceAcDream.UI.Abstractions(the dependency-boundary test), soHeadlessConsoleChatFormatteris a deliberately DIFFERENT, terminal-shaped "[Label] Sender: text" rendering using the SAME channel- name strings (matching the plan's literal[Tell] Bob: hiexample), 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 realHeadlessSessionHost+FixtureSessionOperations, no live server) plus the existingLaunchOptionsDocumentationTests(4/4 green) andHeadlessDependencyBoundaryTests(3/3 green, unchanged — Headless still references onlyAcDream.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(), forcingTryHandlePluginCommandto always return false, dropping the reader thread'sEnqueue, and swappingChatChannelKind.Sayfor.TellinSubmitConsoleLine). 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 onACDREAM_PROBE_LIVE_MOUNT=1, a manual live-DAT probe lane, unrelated to this change) / 3 skipped / 419 total.dotnet build AcDream.slnx -c Releasegreen throughout. -
2026-09-07 FIX ROUND (Opus review, APPROVE-WITH-FIXES). S1:
ACDREAM_ HEADLESS_CONSOLE=0now 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 inLaunchOptionsDocumentationTests.DefaultOnBehaviorFlags(a default-on behavior with an A/B off-switch, once set at all, likeACDREAM_RETAIL_CHASE). S2: the reader-thread pin is now falsifiable — a fixtureTextReaderrecords the actual thread idReadLineran on, and a new test asserts the controller's submit callback runs on neither that thread nor any other unexpected one, only theDrainDuecaller's. S3: oneHeadlessProcessHostend-to-end test proves a console line reaches the session's realSubmitConsoleLinepipeline and/quitreturnsHeadlessExitCode.Success. S4:HeadlessConsoleController.Handlenow wraps_submitin try/catch (mirroringLoginCommandSequence.DrainDue) and prints a line forUnknownCommand/Dropped, so a console typo can never escape into the scheduler's per-session quarantine catch. S5: deleted the per-callHeadlessConsoleChatFeedbackdecorator — it only ever saw text produced by the console's OWNSubmitConsoleLinecalls. The newHeadlessConsoleSpewBoxPumppolls the sharedSpewBoxStateon the console's own per-tick pump instead, the SAME seam the graphical overlay'sSpewBoxController.Tickreads, so server- and plugin-drivenClientLocalinterface text prints too. S6:Program.csnow resolvesstandardOutputIsTerminalnext to the stdin probe and threads it throughHeadlessEntryPoint.Run→HeadlessProcessHost, which no longer readsSystem.Console.IsOutputRedirecteditself. S7: a multi-session process launched with--consolenow 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 realChatCommandRouter.Submitorder: retail's client-command catalog, local/help, plugin verbs, the unregistered- channel-tag fallback, an explicit server command, then plain chat. N2:HeadlessCommandLine.Consolerenamed toConsoleEnabled. N3:validatemode now rejects--consoleoutright rather than silently ignoring it. N4:/statusand/quitare console-intercepted verbs — they never reachChatCommandRouter, unlike@status, which is a real server command and still passes through untouched. N5:HeadlessConsoleRenderernow 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 withCo-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 correctedOnlyTheSixProductBehaviorFlagsDefaultOn(renamed from Five).dotnet build AcDream.slnx -c Releasegreen 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:
- Type
/say helloand 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: helloin this console (the server's own HearSpeech echo, rendered through the normal chat event stream). - Type
/status— expect a line withgeneration=,position=(a real cell/local-frame triple once in world), andplugins=N loaded. - 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@vtserver command). - 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.
- 2026-09-07 narrow re-check: all twelve fix items CLOSED; MERGE-READY. Merged into the campaign branch at
8cb284d6f; the unknown-verb pin re-targeted to the chat scroll after AD-124 (074a1561b). Connected proof PASSED (lead, 2026-09-07):acdream-headless run --config <one idle session, +Acdream> --consolewith scripted stdin —/say hello→ the server's echo printed as[Local] You: hello;/status→generation=1 position=unknown plugins=0 loaded(idle policy has no movement controller);/quit→[session] graceful logout confirmed, exit 0. The MossTank half (/vt start) is owed with slice 2's autostart work. Follow-ups filed as #489 (SpewBox growth without a console; polish; and the JSON diagnostics stream interleaving with chat lines in console mode — the console should quiet or redirect it). Status: CLOSED.