Owner live-client report 2026-09-07: "For scrollable dropdown or the meta
window we use the same assets as we do in for example chat or inventory
window."
<menu> markup wiring (MarkupDocument.cs): a plugin <menu> is now always
Scrollable (single-column, VTank HudCombo shape) instead of wrapping
overflow into more grid columns, with PopupScrollbarHideWhenDisabled=true
so the bar is entirely absent while the item count fits the "rows"
window. RetailScrollbarChrome.ApplyToMenuPopup wires the same chrome ids
the previous commit taught DrawScrollablePopupPlain to draw, for both
style="plain" and style="retail" markup menus.
<list> markup (UiMarkupList.cs / MarkupDocument.cs): a plugin <list>
(single-column or <column> multi-column) that overflows its own row
viewport now draws the retail scrollbar chrome at its right edge (VVS's
own placement, 16px wide) instead of being wheel-scroll-only with no
visible bar. The reserved 16px column only exists while rows actually
overflow, in both column-layout modes (ComputeColumnLayout receives the
already-shrunk width so the last/auto column absorbs the remainder
correctly); the bar is fully interactive (up/down arrows, track paging,
thumb drag) via a small UiScrollable projection kept in sync with the
list's own _topRow, which stays the single source of truth. Wheel
scrolling and a no-resolver hand-built list (draws nothing, no crash) are
unchanged.
Mutation shown to fail first: new
UiMarkupListScrollbarTests/MarkupDocumentTests cases were written against
pre-change UiMarkupList/MarkupDocument and failed (no scrollbar sprites
ever emitted since UiMarkupList had no SpriteResolve property at all, and
<menu> markup never set Scrollable) before the implementation landed;
after: SingleColumn_Overflowing_DrawsRetailScrollbarChromeAtRightEdge and
Columns_Overflowing_ReservesSixteenPixels_LastColumnShrinksAccordingly
pin sprite ids + exact reserved-width geometry,
*_ContentFits_DrawsNo(Scrollbar|ReservationLastColumnKeepsFullRemainder)
pin the no-overflow/no-bar case, *_UpArrowClick_ScrollsUpByOneRow and
ThumbDrag_MovesTopRowAndIsReadableByASubsequentClick pin interactivity via
a following row click resolving to the moved position (mirroring
MarkupListColumnsTests' own wheel-scroll pin), and the four new
MarkupDocumentTests menu cases pin Scrollable/PopupScrollbarHideWhenDisabled/
the six chrome-id properties plus an end-to-end open-popup draw for both
the overflowing (draws chrome) and non-overflowing (draws none) cases.
Every pre-existing MarkupListColumnsTests/MarkupDocumentTests case stays
green unchanged (none of their fixtures overflow their own viewport).
docs/plugin-ui-markup.md updated: the <menu> style paragraph and a new
<list> "Scrollbar" section describe the new chrome + auto-reservation, and
the PITCH-transcription guidance is corrected to say the 16px scrollbar
column is now automatic (no more manual fold-in/double-reservation advice).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner live-client report 2026-09-07: "For scrollable dropdown or the meta
window we use the same assets as we do in for example chat or inventory
window." The plain-style <menu> popup's scrollable-overflow scrollbar
(DrawScrollablePopupPlain / DrawPopupScrollbarPlain in UiMenu.cs) drew a
home-made flat 1px track + flat thumb instead of the gold track + up/down
arrow buttons + thumb the chat SpewBox and inventory UiItemList already
use through RetailScrollbarChrome. The owner only ever objected to the
retail ROW art (checkmark glyph, gradient panel) — the bar itself was
never in scope for the plain-row fix, so this change touches only the
scrollbar draw call and leaves the plain row rendering untouched.
DrawScrollablePopupPlain now calls the existing DrawPopupScrollbar helper
(the same procedural sprite-chrome draw VendorUiController/
ConfigOptionsPageController already use) whenever a SpriteResolve is
wired, falling back to the old flat DrawPopupScrollbarPlain only for a
hand-built UiMenu with no resolver at all. New
RetailScrollbarChrome.ApplyToMenuPopup(UiMenu) wires the same vertical
skin ids (Track/Up/Down/ThumbTop/Mid/Bot Normal) the chat/inventory
scrollbar uses onto a menu's own ScrollTrackSprite/etc properties.
Mutation shown to fail first: UiMenuPlainStyleTests's
Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt
and Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb asserted
resolveCalls==0 and an all-fill scrollbar — both failed (6 resolve calls,
6 sprite quads instead of 0) against the new DrawPopupScrollbar call
before being rewritten to
Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain
and Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll, which pin the
new sprite-chrome behavior (6 resolved ids on overflow: track, up, down,
thumb top/mid/bottom; 3 on content-fits: track+up+down, no thumb; 0 on a
menu built with no resolver) while re-asserting the rows are still plain
fills with zero retail row-sprite quads. Retail's own
RetailButtonArt=true popup path (DrawGridPopup/DrawScrollablePopup) is
untouched — its regression golden
(Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden) still
passes byte-for-byte.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to the UiMenu popup fix: the existing "menu style" paragraph in
plugin-ui-markup.md only described the closed-state button face swap from
the earlier S7 fix. Extend it to say the plain style now covers the whole
menu (closed AND open) — flat popup chrome matching <list>, a lighter hover
fill, no checkmark, and a plain scrollbar past the row cap — so a plugin
author reading the doc doesn't assume style="plain" only affects the
closed face.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner live-client report 2026-09-07 ("Drop down menus look horrible, there
is also a checkmark on the text there"): the prior S7 fix only replaced the
CLOSED-state button face when RetailButtonArt=false. The OPEN popup still
drew retail's sprite art unconditionally — the tan/orange gradient panel
(PopupBgSprite), the per-row highlight sprite (which bakes a checkbox/
checkmark glyph into its leftmost ~17px, per TextIndent's doc comment), and
the ornate gold scrollbar chrome — regardless of the menu's style.
UiMenu.OnDrawOverlay now branches on RetailButtonArt before ever reading
SpriteResolve: plain mode draws through two new methods (DrawGridPopupPlain,
DrawScrollablePopupPlain) that use only DrawFill/DrawRectOutline — flat
background + 1px border, the current entry filled like a list selection
(PlainSelectedColor, same value as UiMarkupList.SelectedColor), a new
hover fill (PlainHoverColor) for the row under the pointer, and left-aligned
text at PlainPadding. No checkmark is possible by construction since plain
mode never resolves or draws any sprite. Hover tracking needed a small new
mechanism: UiMenu.ReceivesHoverMouseMove now returns true while a plain
popup is open, so UiRoot's hover dispatch keeps delivering MouseMove to
_hoveredPopupIndex (reset on every open/close transition and on
HoverLeave). Scrollbar overflow (DrawPopupScrollbarPlain) draws a 1px-
bordered track and a flat thumb, both in PlainBorderColor, sharing the
exact UiScrollbar.ThumbRect geometry the hit-test math already uses — no
DAT track/thumb/arrow-button art. Hit-testing (OnHitTest/OnEvent's
MouseDown pick logic) is untouched; the retail sprite branch is now a
separate, unmodified path proven byte-identical by a new golden test.
Mutation proof: reverting UiMenu.cs alone (keeping the new tests) fails the
build outright — the six new tests reference PlainSelectedColor/
PlainHoverColor, which only exist after this change (CS1061 with the old
class). Filters run: AcDream.App.Tests Markup|UiMenu|Menu|Scrollbar
(242 passed, 3 pre-existing unrelated Lane=Manual live-DAT-probe failures
that require ACDREAM_PROBE_LIVE_MOUNT=1 and predate this change) and
AcDream.Plugins.MossTank.Tests Markup (9/9 passed).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner 2026-09-07: plugin output and 'Unknown command' land in the chat
scroll, not the SpewBox. Plugin text = Decal/VTank-faithful; the
unknown-command re-route is recorded as AD-124 (retail types it 0x1A).
Bad-argument refusals of real retail commands stay SpewBox-only (AP-183).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner direction 2026-09-07 (same instruction as the sibling unknown-
command fix, previous commit): plugin-originated text must land in
the chat window instead of retail's ClientLocal (0x1A) SpewBox-only
channel. AppAutomationSurface.PostSystemMessage -- the production
implementation of IPluginChat.PostSystemMessage that MossTank/VTank-
style plugins call -- now passes RetailLogTextType.Default instead of
ClientLocal to RuntimeCommunicationState.AddText, so the text reaches
the chat transcript via Chat.OnSystemMessage instead of the SpewBox.
This matches Decal's own AddChatText behavior for plugin output.
IPluginChat.PostSystemMessage's doc comment is updated to describe
the new destination instead of the old one. Register row AD-124
(previous commit) already covers this site alongside the sibling
unknown-command change.
Mutation check: temporarily reverted PostSystemMessage's AddText call
back to ClientLocal and confirmed the new
AppAutomationSurfaceTests.PostSystemMessage_RoutesToChatLog_NeverSpewBox
test fails (Assert.Single() on an empty chat log) before restoring the
fix. Also adds ChatVMTests.RecentLines_ShowsPluginSystemMessage_TaggedDefault
pinning that a ChatVM bound to the same ChatLog surfaces the line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner direction 2026-09-07 (verbatim): "Unknown commands like /vt or
stuff from plugins shall now go to the SpewBox. They should go to the
chatbox." Retail itself types ChatCommandRouter's "Unknown command"
refusals as ClientLocal (0x1A) -- the bit every ChatInterface window's
default filter excludes, so they only ever reached the transient
SpewBox overlay and left no transcript record.
Three call sites in ChatCommandRouter.Submit/EmitVerbHelp now call
IChatCommandFeedback.ShowSystemMessage (chat scroll, retail
Default/0x00) instead of ShowInterfaceText (SpewBox): the degenerate-
prefix "Unknown command: {verb}." guard, EmitVerbHelp's confirmed-
null-help branch, and EmitVerbHelp's unresolved-verb fallback. Every
OTHER 0x1A refusal in this file (AP-183 bad-argument refusals of REAL
retail commands -- lifestone, marketplace, channel list/on/off,
allegiance, house, the generic HandleFailureEvent(0x26) fallback,
DoStupidChannelHack, DoReply) is unchanged and still SpewBox-only --
the owner named only unknown commands and plugin text.
This is a deliberate deviation from retail's own 0x1A typing, recorded
as register row AD-124 (also covers the sibling plugin-text change in
a follow-up commit). docs/ISSUES.md #363/#367 get a one-line note
under each pointing at the re-route; their CLOSED status is untouched.
Mutation check: temporarily reverted all three ShowSystemMessage call
sites back to ShowInterfaceText and confirmed the 3 new/changed pinned
tests fail (Assert.Single() on an empty chat log) while the AP-183
boundary test (real command, bad args, still SpewBox) continues to
pass -- see ChatCommandRouterFeedbackRoutingTests.cs and the updated
ChatCommandRouterTests.cs/RetailCommandHelpTableTests.cs assertions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
UiMenu.RetailButtonArt (default true; every retail user unchanged);
MarkupDocument sets plain for plugin markup, style="retail" opts back.
Lead-reviewed diff; 10 new tests; goldens for the retail path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
Adds style to the <menu> attribute row and one sentence explaining why
plain is now the default (owner report: retail's gold pushbutton art
read as an out-of-place button next to a plugin's own dark list boxes)
and what style="retail" opts back into.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wires the new UiMenu.RetailButtonArt switch (previous commit) into
plugin markup: <menu style="plain"> (also the default when the
attribute is absent) builds RetailButtonArt=false so a plugin's
dropdown gets the flat VTank-matching box; style="retail" opts a
panel back into the gold pushbutton face. Any other value throws
FormatException at Build naming the element, matching the existing
<icon iconkind> validation convention (ValidateIconKind).
Mutation check: temporarily stubbed ValidateMenuStyle to always return
true (as if the switch didn't exist) — 3 of the 4 new
MarkupDocumentTests.Menu_* tests failed exactly as expected
(Menu_NoStyleAttribute_DefaultsToPlain_RetailButtonArtFalse,
Menu_StylePlain_Explicit_RetailButtonArtFalse,
Menu_UnknownStyle_ThrowsFormatException_NamingTheElement); the
style="retail" test passed trivially either way, as expected for that
case. Restored before committing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner live-client report 2026-09-07: "Those BIG gold/yellow buttons HAS
to go. That is not how vtank looks." VTank/Decal's HudCombo is a flat
dark box (background/border matching its own HudList) with a
left-aligned value and a small down-arrow — retail's gold pushbutton
art (the 3-slice LED-arrow face UiMenu.DrawButtonFace draws) is a
different widget family entirely.
Adds UiMenu.RetailButtonArt (default true, so every existing
non-markup UiMenu caller — chat's channel menu, vendor's category
dropdown, Config's option menus, the retail confirmation dialog, and
DatWidgetFactory's generic Type-6 element — keeps its byte-identical
retail face) plus DrawPlainClosedState/DrawPlainTriangle, which draw
the flat box entirely with UiRenderContext.DrawFill/DrawRectOutline (no
sprite or DAT quad at all) using colors mirroring UiMarkupList's own
chrome (background 0,0,0,0.92; border 0.46,0.37,0.16,1; text
0.91,0.87,0.76,1). Open/pressed only tints the border
(0.70,0.58,0.24,1) — never a sprite swap.
Mutation check: temporarily disabled the new `if (!RetailButtonArt)`
branch in OnDraw (reverting it to the pre-fix unconditional retail
path) — 3 of the 6 new UiMenuPlainStyleTests failed exactly as
expected (Plain_ClosedState_DrawsNoTexturedFaceQuad,
Plain_ClosedState_DrawsFillOutlineTextAndTriangle,
Plain_ClosedState_TriangleSitsRightAligned_TextSitsAtListPadding); the
3 retail-path/default-value tests kept passing since they don't
exercise the removed branch. Restored before committing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessConsoleRenderer dimmed every line uniformly, so chat and
interface text (player-visible content) read the same washed-out weight
as scheduling/session-status noise like "entered world" or "command
rejected: ...". Only lifecycle, command, and portal lines are scheduling
noise; chat and interface text now print at the terminal's default
weight.
ChatAndInterfaceTextPrintAtDefaultWeightNeverDimmed was shown to fail
against the prior dim-everything WriteLine (mutation: dim parameter not
yet threaded through, every call still unconditionally wrapped in the
ANSI dim/reset pair) -- the chat line and interface text both carried
the dim escape sequence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Existing coverage exercises HeadlessSessionHost.SubmitConsoleLine
directly (bypassing the background reader thread and the scheduler) or
HeadlessConsoleController in isolation (a fake submit callback, no real
session). Add one test that drives the actual production wiring: a
plain StringReader feeds "hello" and "/quit" through the real
HeadlessProcessHost constructor (background reader thread -> per-tick
ConsolePump -> ChatCommandRouter.Submit -> the wire), asserting the
outbound Talk action reaches the fixture AND that /quit ends
RunAsync with HeadlessExitCode.Success -- the same graceful path an
external cancellation takes.
Mutation: removed the session.ConsolePump assignment in
HeadlessProcessHost's constructor (never wiring the drain+pump
delegate). The test failed with a TimeoutException -- the queued
console lines were never drained, so /quit's cancellation never fired
and RunAsync ran until the test's own 10s WaitAsync bound.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A configured process with 2+ sessions and --console silently skipped
console attachment (the plan's "single-session only for the first cut"),
indistinguishable from --console simply having worked. Report it
explicitly through the same HeadlessDiagnosticWriter.Message stream
every other structured event already uses.
TwoSessionsWithConsoleFlagReportsSingleSessionOnly was shown to fail
against the silent-skip branch (mutation: the else-branch body removed)
-- the diagnostics stream carried only the ordinary lifecycle/resource
JSON lines, with no "single-session only" message anywhere.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessProcessHost read System.Console.IsOutputRedirected directly to
pick the console renderer's color mode, which only Program.cs (the
executable's own entry point) should ever touch -- the same reasoning
that already put the stdin probe there. Resolve
standardOutputIsTerminal next to the existing !Console.IsInputRedirected
probe in Program.cs and thread it through HeadlessEntryPoint.Run into
HeadlessProcessHost's constructor as a plain parameter.
StandardOutputIsTerminalParameterControlsColorNotTheRealConsole was
shown to fail with the parameter still unused (useColor still reading
the real Console.IsOutputRedirected, which the test host always
redirects): the standardOutputIsTerminal:true case expected dimmed
lifecycle output but got none, since the real console read forced
useColor=false regardless of what the test passed in.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessConsoleChatFeedback wrapped RuntimeChatCommandFeedback per
SubmitConsoleLine call, so it only ever saw interface text produced by
the console's OWN typed line -- a server-driven refusal or a plugin's
own Log/interface-text write (RuntimeCommunicationState.AddText's
ClientLocal branch, called from anywhere else) never reached the
console at all, because that branch enqueues into SpewBoxState and
never touches ChatLog/RuntimeChatDelta.
Delete the decorator. HeadlessConsoleSpewBoxPump instead polls the SAME
SpewBoxState the graphical overlay's SpewBoxController.Tick already
reads, diffing against the previous visible snapshot so it prints only
newly-appeared entries. HeadlessProcessHost's ConsolePump now runs the
input drain and the SpewBox pump together each tick.
HeadlessSessionHost.SubmitConsoleLine drops its onInterfaceText
parameter -- it is just ChatCommandRouter.Submit against a plain
RuntimeChatCommandFeedback now, same as LoginCommandSequence.
N1: also corrected this method's own doc comment, which described
dispatch as "plugin verb registry first, then retail client/server
slash commands" -- the real ChatCommandRouter.Submit order is retail's
catalog, local /help, plugin verbs, the channel-tag fallback, an
explicit server command, then chat.
PumpPrintsInterfaceTextNotOriginatingFromTheConsole was shown to fail
against a no-op Pump() (mutation) -- the enqueued plugin-shaped line
never printed. UnknownVerbProducesTheSameInterfaceTextTheChatBoxShows
was reworked to assert against the real SpewBoxState directly instead
of the deleted decorator's callback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessConsoleController.Handle called _submit(rawLine) bare: an
UnknownCommand/Dropped outcome printed nothing (the operator had no way
to tell their line did nothing), and any exception from the submit
callback would propagate out of DrainDue into the scheduler's
per-session quarantine catch, faulting the whole session over one
console typo. Wrap the submit in try/catch and report both cases with a
visible line, mirroring LoginCommandSequence.DrainDue's own reporting
for login-line failures.
N1: also corrected this class's own <remarks> doc comment, which
described the dispatch order as "plugin-verb registry -> client/server
slash commands" -- the real ChatCommandRouter.Submit order is retail's
client-command catalog, then local /help, then plugin verbs, then the
unregistered-channel-tag fallback, then an explicit server command, then
chat.
UnknownOrDroppedOutcomePrintsAVisibleLine and
SubmitFailurePrintsALineAndNeverEscapesDrainDue were shown to fail
against the prior bare `_submit(rawLine);` call: the outcome tests found
nothing printed, and the failure test caught the InvalidOperationException
escaping DrainDue itself rather than being reported as a line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The existing ordering test proves lines drain in order on the calling
thread but only argues "never the reader thread" structurally (the
reader loop has no dispatch code to run). Add ThreadIdRecordingTextReader,
which records the actual managed thread id ReadLine() ran on, and assert
from inside the controller's submit callback that the executing thread is
neither that reader thread nor any other thread -- only the DrainDue
caller's.
Mutation: routed DrainDue's Handle(line) call through a dedicated
new Thread(...).Start()/Join() instead of calling it inline. The new
test failed (drainCallerThreadId != observedSubmitThreadId, off by one
full OS thread) before reverting the mutation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessConsoleOptions.Resolve tested the environment variable against
the literal "1", so ACDREAM_HEADLESS_CONSOLE=0 silently fell through to
the terminal-shaped default (on when stdin is a real console) instead of
acting as an A/B off-switch. Now: once the variable is SET AT ALL, any
value other than "0" enables and "0" disables -- the same idiom
ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI already use. An unset
variable still falls through to the terminal default.
Registered the flag as the sixth entry in
LaunchOptionsDocumentationTests.DefaultOnBehaviorFlags and updated the
Conventions section of docs/launch-options.md plus the flag's own row
(side-effects column corrected to describe the real precedence).
ResolvePrefersFlagThenEnvironmentThenTerminalDefault's env="0"/terminal
=true case was shown to fail against the prior `== "1"` implementation
(expected false, old code returned true) before the fix landed; the
env="yes" case also failed on the same mutation (old code required the
literal "1", so "yes" fell through to terminal=false instead of enabling).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
validate never starts a session, so a silently-ignored --console read as
"it worked" to an operator who typo'd their command. Reject with a clear
message instead.
New test ValidateModeRejectsTheConsoleFlag was shown to fail first
(mutation: the guard absent — HeadlessCommandLine.Parse returned normally
for `validate --config bot.json --console` instead of throwing).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Console" read as if it might mean System.Console; ConsoleEnabled says
what the flag actually gates. Pure rename, no behavior change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Appends the plan's ledger with what shipped, the deliberate deviation
from the plan's illustrative bracket-prose example (Headless cannot
reference AcDream.UI.Abstractions, so the console's chat rendering is
its own terminal-shaped format using the same channel-name strings,
not a byte-for-byte port of ChatVM.FormatEntry), the chosen console
default and why, and the exact connected proof recipe for the owner to
run against a live ACE with +Acdream and MossTank (not run here per
the contract).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
20 focused tests, no live server: typed-option resolution and
--console flag parsing; the reader-thread ordering guarantee (lines
queued by the background thread drain, in FIFO order, entirely on the
calling thread); the controller's drain/quit/status behavior; the
console chat formatter's per-kind bracket labels; and the full
dispatch pipeline against a real HeadlessSessionHost wired to a
no-network FixtureSessionOperations fixture (mirrors the existing
HeadlessSessionHostTests pattern used for LoginCommandSequence):
/say and plain text both produce the identical outbound Talk action a
graphical /say would send, a registered plugin verb is reached without
touching the wire, and an unknown/degenerate verb produces the same
interface text ChatCommandRouterTests already pins for the graphical
route.
Every test in this file was run against a deliberate one-line mutation
of its own production code first and confirmed red before being
reverted: HeadlessConsoleChatFeedback dropping its interface-text
callback, HeadlessConsoleController skipping
_quitRequested.Cancel(), LiveChatCommandRoute.TryHandlePluginCommand
forced to always return false, HeadlessConsoleInputReader's read loop
dropping its Enqueue call, and SubmitConsoleLine's ChatChannelKind.Say
swapped for .Tell.
dotnet test tests/AcDream.Headless.Tests -c Release: 193 passed, 1
pre-existing failure (LinuxRejectsGroupOrOtherCredentialPermissions -
Linux-only lane, cannot run on this Windows host, unrelated), 194
total. dotnet test tests/AcDream.Runtime.Tests -c Release: 1891/1891.
dotnet test tests/AcDream.App.Tests -c Release --filter
"FullyQualifiedName~Chat|FullyQualifiedName~Command|FullyQualifiedName~LaunchOptions":
414 passed, 2 pre-existing failures (both gated on
ACDREAM_PROBE_LIVE_MOUNT=1, a manual live-DAT probe lane, unrelated),
3 skipped, 419 total.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Console mode for the headless bot host, enabled by --console or
ACDREAM_HEADLESS_CONSOLE=1 (default: on when stdin is a real terminal,
off when redirected — a background reader thread blocked on ReadLine
would otherwise sit idle against a script/CI pipe).
- HeadlessConsoleInputReader: one background thread per console,
reading lines into a ConcurrentQueue. It only ever enqueues — no
dispatch code runs on it — so input queued while the session tick is
busy is guaranteed to execute on the tick thread, never the reader
thread, when drained.
- HeadlessConsoleController: drains the queue via
HeadlessSessionHost.ConsolePump (a new no-op-by-default tick hook),
handles /quit (cancels a CancellationTokenSource) and /status
(prints a caller-supplied status string), and routes everything else
through SubmitConsoleLine.
- HeadlessConsoleChatFormatter/HeadlessConsoleRenderer: render the K2
bot event stream (IRuntimeEventObserver — the same interface a bot
policy subscribes) as bracket-labelled lines ([Tell] Bob: hi,
[Fellowship] ..., [Local] ...) plus lifecycle/portal/rejected-command
lines. A deliberate departure from ChatVM.FormatEntry's retail prose:
Headless cannot reference AcDream.UI.Abstractions (the dependency-
boundary test), so this is its own terminal-shaped rendering using
the same channel-name strings, not a byte-for-byte port.
- HeadlessProcessHost: attaches the console only for a single-session
run (multi-session is out of scope for this cut per the plan),
constructed AFTER every session's own credential resolution so the
reader thread never races a StandardInput-provider password prompt
on the same stdin stream. /quit's CancellationTokenSource is linked
into the scheduler's run token, so it exits through the exact same
graceful path an external Ctrl+C/SIGTERM already takes.
Mutation check (reverted before commit): removing the reader thread's
Enqueue call and removing _quitRequested.Cancel() each turn a
still-to-be-added HeadlessConsoleTests case red.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessSessionHost already built ChatCommandRouter.Submit's exact
dependencies (LiveChatCommandSurface over the plugin-verb registry, a
RuntimeChatCommandFeedback) for LoginCommandSequence — that pipeline is
the same one every graphical chat window calls. No lift was needed;
this promotes those two ctor locals to fields and adds
SubmitConsoleLine, the console's one entry point for a typed line.
HeadlessConsoleChatFeedback decorates the real feedback so the
console ALSO sees retail's transient SpewBox/ClientLocal interface
text (bad-args refusals, unknown-command text) — that path never
touches ChatLog, so it never reaches the K2 event-stream renderer
added in the next commit.
Mutation check (reverted before commit): commenting out
_onInterfaceText(text) in HeadlessConsoleChatFeedback.ShowInterfaceText,
and swapping SubmitConsoleLine's ChatChannelKind.Say for .Tell, each
turn a still-to-be-added HeadlessConsoleTests case red.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the first piece of the interactive headless console (docs/plans/
2026-09-07-headless-console.md): HeadlessConsoleOptions.Resolve picks
--console, then ACDREAM_HEADLESS_CONSOLE=1, then a terminal-shaped
default, matching the project's typed-options-object convention rather
than a scattered env-var read. HeadlessCommandLine.Parse now accepts
--console as a bare flag (no value token) alongside the existing
paired options. Both new launch-options.md rows are added in this
commit per LaunchOptionsDocumentationTests' bidirectional rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A route (.af) file placed in metas/ used to silently "succeed" as an empty
MetaProfile: TryLoadMeta's STATE:/NAV: loop never adds a Rule for a NAV:-only
file, so the mistake was invisible. TryLoadMeta now tracks whether it saw
any STATE: block; a file with at least one NAV: block and zero STATE:
blocks throws (same FormatException path every other malformed-content
error already uses), naming the navs/ folder the file actually belongs in.
The opposite direction was already structurally caught by TryLoadNav's
existing "no NAV: block found" throw when a file has zero NAV: blocks (a
Meta profile with no embedded route, placed in navs/) — only the message
text is improved to name the metas/ folder. Both notices flow unchanged
through the existing MossTankProfileRecovery.Preserve/RecoveryNotice path
in MossTankMetaProfileStore.LoadCurrent/MossTankRouteProfileStore.LoadCurrent,
so no store-side code changes were needed for the wiring itself.
Mutation demonstrated: `git stash push -- src/AcDream.Plugins.MossTank/MetafSerializer.cs`
(reverting only the production fix, keeping every new test) reproduced 8
failures — the 5-fixture EveryNavOnlyAfFixtureIsRefusedByTryLoadMeta theory
(every real nav_*.af fixture parsed as a "successful" empty MetaProfile),
MetaOnlyContentIsRefusedByTryLoadNavWithMetasFolderNotice (message lacked
"metas/"), and the two store-level tests
MetaStoreRefusesToLoadANavOnlyFileWithNoticeNamingNavsFolder/
RouteStoreRefusesToLoadAMetaOnlyFileWithNoticeNamingMetasFolder — confirmed
by running the suite with the stash applied, then `git stash pop` to
restore the fix. All 640 tests (632 + 8 new) pass after.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>